From 720342f880540613d7b6fe2894ba9d3633ba8ddc Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Sun, 5 Apr 2026 20:16:24 -0300 Subject: [PATCH] refactor(api): expand and test api as stealth http interface --- Cargo.toml | 1 + api/Cargo.toml | 29 +++++ api/src/error.rs | 77 +++++++++++++ api/src/lib.rs | 23 ++++ api/src/main.rs | 209 ++++++++++++++++++++++++++++++++++ api/src/preflight.rs | 135 ++++++++++++++++++++++ api/src/routes/mod.rs | 1 + api/src/routes/wallet.rs | 200 ++++++++++++++++++++++++++++++++ api/tests/e2e_regtest_scan.rs | 163 ++++++++++++++++++++++++++ api/tests/http_scan.rs | 158 +++++++++++++++++++++++++ engine/src/engine.rs | 6 +- model/src/config.rs | 34 ------ model/src/descriptor.rs | 4 +- 13 files changed, 1001 insertions(+), 39 deletions(-) create mode 100644 api/Cargo.toml create mode 100644 api/src/error.rs create mode 100644 api/src/lib.rs create mode 100644 api/src/main.rs create mode 100644 api/src/preflight.rs create mode 100644 api/src/routes/mod.rs create mode 100644 api/src/routes/wallet.rs create mode 100644 api/tests/e2e_regtest_scan.rs create mode 100644 api/tests/http_scan.rs diff --git a/Cargo.toml b/Cargo.toml index 8d61bfd..76983a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "model", "bitcoincore", "engine", + "api", ] resolver = "2" diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..d5895ba --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "stealth-api" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "HTTP transport for Stealth wallet privacy analysis" +categories = ["cryptography::cryptocurrencies", "web-programming::http-server"] +keywords = ["bitcoin", "privacy", "api", "wallet"] +readme = "README.md" + +[dependencies] +axum = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +stealth-bitcoincore = { path = "../bitcoincore" } +stealth-engine = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +corepc-node = { workspace = true } +http-body-util = "0.1.3" +reqwest = { version = "0.12.9", default-features = false, features = ["json", "rustls-tls"] } +tower = { version = "0.5.2", features = ["util"] } diff --git a/api/src/error.rs b/api/src/error.rs new file mode 100644 index 0000000..ddac98e --- /dev/null +++ b/api/src/error.rs @@ -0,0 +1,77 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use thiserror::Error; + +use crate::preflight::ValidationError; +use stealth_engine::error::AnalysisError; + +#[derive(Debug, Error)] +pub enum ApiError { + #[error("{0}")] + BadRequest(String), + #[error("validation failed: {0}")] + Validation(#[from] ValidationError), + #[error("analysis failed: {0}")] + Analysis(#[from] AnalysisError), + #[error("scanner not configured – set STEALTH_RPC_URL")] + ScannerNotConfigured, + #[error("internal error: {0}")] + Internal(String), +} + +impl ApiError { + pub fn bad_request(message: impl Into) -> Self { + Self::BadRequest(message.into()) + } + + fn status_code(&self) -> StatusCode { + match self { + Self::BadRequest(_) | Self::Validation(_) => StatusCode::BAD_REQUEST, + Self::Analysis(AnalysisError::EmptyDescriptor) + | Self::Analysis(AnalysisError::DescriptorNormalization { .. }) => { + StatusCode::BAD_REQUEST + } + Self::Analysis(AnalysisError::EnvironmentUnavailable(_)) => StatusCode::BAD_GATEWAY, + Self::Analysis(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::ScannerNotConfigured => StatusCode::SERVICE_UNAVAILABLE, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_code(&self) -> &'static str { + match self { + Self::BadRequest(_) => "bad_request", + Self::Validation(_) => "invalid_scan_input", + Self::Analysis(_) => "scan_failed", + Self::ScannerNotConfigured => "scanner_not_configured", + Self::Internal(_) => "internal_error", + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = self.status_code(); + let message = self.to_string(); + let code = self.error_code(); + let body = Json(ErrorResponse { + error: ErrorDetails { code, message }, + }); + (status, body).into_response() + } +} + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: ErrorDetails, +} + +#[derive(Debug, Serialize)] +struct ErrorDetails { + code: &'static str, + message: String, +} diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 0000000..6c91b0f --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,23 @@ +mod error; +mod preflight; +mod routes; + +use std::sync::Arc; + +use axum::Router; +use stealth_engine::gateway::BlockchainGateway; + +/// Shared application state: an optional blockchain gateway. +pub type GatewayState = Option>; + +/// Build the router without a gateway (503 on every scan request). +pub fn app() -> Router { + app_with_gateway(None) +} + +/// Build the router with a concrete [`BlockchainGateway`]. +pub fn app_with_gateway(gateway: GatewayState) -> Router { + Router::new() + .nest("/api/wallet", routes::wallet::router()) + .with_state(gateway) +} diff --git a/api/src/main.rs b/api/src/main.rs new file mode 100644 index 0000000..f27ec13 --- /dev/null +++ b/api/src/main.rs @@ -0,0 +1,209 @@ +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use stealth_api::app_with_gateway; +use stealth_bitcoincore::{read_cookie_file, BitcoinCoreRpc}; +use stealth_engine::gateway::BlockchainGateway; +use tracing_subscriber::EnvFilter; + +fn main() -> Result<(), Box> { + init_tracing(); + + // `BitcoinCoreRpc` uses `reqwest::blocking::Client`, which must be + // constructed outside a Tokio runtime. + let gateway: Arc = Arc::new(build_gateway()?); + let bind_addr = read_bind_addr() + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(run_server(bind_addr, gateway.clone()))?; + // Keep one strong reference until after async serving ends so the blocking + // client drops outside async context. + drop(gateway); + Ok(()) +} + +async fn run_server( + bind_addr: SocketAddr, + gateway: Arc, +) -> std::io::Result<()> { + let app = app_with_gateway(Some(gateway)); + let listener = tokio::net::TcpListener::bind(bind_addr).await?; + + tracing::info!(%bind_addr, "stealth-api listening"); + axum::serve(listener, app).await?; + Ok(()) +} + +fn read_bind_addr() -> Result { + let raw = std::env::var("STEALTH_API_BIND").unwrap_or_else(|_| "127.0.0.1:20899".to_owned()); + raw.parse::() + .map_err(|_| format!("invalid STEALTH_API_BIND value: {raw}")) +} + +fn build_gateway() -> Result> { + let url = std::env::var("STEALTH_RPC_URL").unwrap_or_else(|_| auto_detect_rpc_url()); + let env_user = std::env::var("STEALTH_RPC_USER").ok(); + let env_pass = std::env::var("STEALTH_RPC_PASS").ok(); + let env_cookie = std::env::var("STEALTH_RPC_COOKIE").ok(); + + let (user, pass) = if let (Some(user), Some(pass)) = (env_user, env_pass) { + tracing::info!(rpc_url = %url, rpc_auth = "userpass", "stealth-api RPC configured"); + (Some(user), Some(pass)) + } else if let Some(cookie_path) = env_cookie { + let (u, p) = read_cookie_file(Path::new(&cookie_path))?; + tracing::info!(rpc_url = %url, rpc_auth = "cookie", "stealth-api RPC configured"); + (Some(u), Some(p)) + } else if let Some((user, pass)) = read_bitcoin_conf_credentials() { + tracing::info!(rpc_url = %url, rpc_auth = "bitcoin.conf", "stealth-api RPC configured"); + (Some(user), Some(pass)) + } else if let Some(cookie) = detect_cookie_file(&url) { + let (u, p) = read_cookie_file(&cookie)?; + tracing::info!(rpc_url = %url, rpc_auth = "cookie", "stealth-api RPC configured"); + (Some(u), Some(p)) + } else { + tracing::info!(rpc_url = %url, rpc_auth = "none", "stealth-api RPC configured"); + (None, None) + }; + + Ok(BitcoinCoreRpc::from_url(&url, user, pass)?) +} + +fn init_tracing() { + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(env_filter) + .without_time() + .try_init(); +} + +fn auto_detect_rpc_url() -> String { + // Prefer regtest first for local development workflows. + const CANDIDATES: [(&str, &str); 4] = [ + ("http://127.0.0.1:18443", "regtest"), + ("http://127.0.0.1:8332", "mainnet"), + ("http://127.0.0.1:18332", "testnet"), + ("http://127.0.0.1:38332", "signet"), + ]; + + for (url, network) in CANDIDATES { + if rpc_port_reachable(url) { + tracing::info!(rpc_url = %url, %network, "auto-detected local bitcoind RPC"); + return url.to_owned(); + } + } + + let fallback = "http://127.0.0.1:8332".to_owned(); + tracing::warn!( + rpc_url = %fallback, + "could not auto-detect a local bitcoind RPC port; using fallback" + ); + fallback +} + +fn rpc_port_reachable(url: &str) -> bool { + let Some((host, port)) = host_port_from_url(url) else { + return false; + }; + + let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else { + return false; + }; + + for addr in addrs { + if TcpStream::connect_timeout(&addr, Duration::from_millis(150)).is_ok() { + return true; + } + } + false +} + +fn host_port_from_url(url: &str) -> Option<(String, u16)> { + let without_scheme = url + .strip_prefix("http://") + .or_else(|| url.strip_prefix("https://")) + .unwrap_or(url); + let authority = without_scheme.split('/').next()?; + let (host, port_str) = authority.rsplit_once(':')?; + let port = port_str.parse::().ok()?; + Some((host.to_owned(), port)) +} + +fn detect_cookie_file(url: &str) -> Option { + let home = std::env::var_os("HOME")?; + let bitcoin_dir = PathBuf::from(home).join(".bitcoin"); + let port = host_port_from_url(url) + .map(|(_, port)| port) + .unwrap_or(8332); + + for candidate in cookie_candidates(&bitcoin_dir, port) { + if candidate.exists() { + return Some(candidate); + } + } + cookie_candidates(&bitcoin_dir, port) + .into_iter() + .find(|candidate| candidate.exists()) +} + +fn cookie_candidates(bitcoin_dir: &Path, port: u16) -> Vec { + match port { + 18443 => vec![ + bitcoin_dir.join("regtest/.cookie"), + bitcoin_dir.join(".cookie"), + ], + 18332 => vec![ + bitcoin_dir.join("testnet4/.cookie"), + bitcoin_dir.join("testnet3/.cookie"), + bitcoin_dir.join("testnet/.cookie"), + bitcoin_dir.join(".cookie"), + ], + 38332 => vec![ + bitcoin_dir.join("signet/.cookie"), + bitcoin_dir.join(".cookie"), + ], + _ => vec![bitcoin_dir.join(".cookie")], + } +} + +fn read_bitcoin_conf_credentials() -> Option<(String, String)> { + let conf_path = PathBuf::from("bitcoin.conf"); + let conf = std::fs::read_to_string(conf_path).ok()?; + + let mut user: Option = None; + let mut pass: Option = None; + + for raw_line in conf.lines() { + let line = raw_line.trim(); + if line.is_empty() + || line.starts_with('#') + || line.starts_with(';') + || line.starts_with('[') + { + continue; + } + + let Some((raw_key, raw_value)) = line.split_once('=') else { + continue; + }; + let key = raw_key.trim(); + let value = raw_value.trim(); + if value.is_empty() { + continue; + } + + match key { + "rpcuser" => user = Some(value.to_owned()), + "rpcpassword" => pass = Some(value.to_owned()), + _ => {} + } + } + + match (user, pass) { + (Some(user), Some(pass)) => Some((user, pass)), + _ => None, + } +} diff --git a/api/src/preflight.rs b/api/src/preflight.rs new file mode 100644 index 0000000..2553de0 --- /dev/null +++ b/api/src/preflight.rs @@ -0,0 +1,135 @@ +use stealth_engine::engine::ScanTarget; +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ValidationError { + #[error("invalid scan input: {0}")] + InvalidInput(String), +} + +/// Validate and normalize a [`ScanTarget`] before scanning. +/// +/// Returns the validated target unchanged, or an error if the input +/// fails structural validation. +pub fn validate(target: ScanTarget) -> Result { + match &target { + ScanTarget::Descriptor(d) => { + validate_descriptor_shape(d)?; + } + ScanTarget::Descriptors(ds) => { + if ds.is_empty() { + return Err(ValidationError::InvalidInput( + "descriptors cannot be empty".to_owned(), + )); + } + for (index, descriptor) in ds.iter().enumerate() { + if let Err(ValidationError::InvalidInput(message)) = + validate_descriptor_shape(descriptor) + { + return Err(ValidationError::InvalidInput(format!( + "descriptors[{index}] {message}", + ))); + } + } + } + ScanTarget::Utxos(utxos) => { + if utxos.is_empty() { + return Err(ValidationError::InvalidInput( + "utxos cannot be empty".to_owned(), + )); + } + } + } + Ok(target) +} + +fn validate_descriptor_shape(descriptor: &str) -> Result<(), ValidationError> { + let trimmed = descriptor.trim(); + if trimmed.is_empty() { + return Err(ValidationError::InvalidInput( + "descriptor cannot be blank".to_owned(), + )); + } + + let (body, checksum) = split_descriptor_checksum(trimmed)?; + if let Some(checksum) = checksum { + validate_descriptor_checksum_shape(checksum)?; + } + + if body.chars().any(char::is_whitespace) { + return Err(ValidationError::InvalidInput( + "descriptor cannot contain whitespace".to_owned(), + )); + } + if !is_supported_descriptor_prefix(body) { + return Err(ValidationError::InvalidInput( + "descriptor has unsupported script form".to_owned(), + )); + } + if !body.ends_with(')') { + return Err(ValidationError::InvalidInput( + "descriptor must end with ')'".to_owned(), + )); + } + if !has_balanced_parentheses(body) { + return Err(ValidationError::InvalidInput( + "descriptor has unbalanced parentheses".to_owned(), + )); + } + if body + .split_once('(') + .map(|(_, inner)| inner.trim_end_matches(')').trim().is_empty()) + .unwrap_or(true) + { + return Err(ValidationError::InvalidInput( + "descriptor payload cannot be empty".to_owned(), + )); + } + + Ok(()) +} + +fn split_descriptor_checksum(descriptor: &str) -> Result<(&str, Option<&str>), ValidationError> { + let mut parts = descriptor.split('#'); + let body = parts.next().expect("split always returns first element"); + let checksum = parts.next(); + if parts.next().is_some() { + return Err(ValidationError::InvalidInput( + "descriptor contains multiple checksum separators ('#')".to_owned(), + )); + } + Ok((body, checksum)) +} + +fn validate_descriptor_checksum_shape(checksum: &str) -> Result<(), ValidationError> { + if checksum.len() != 8 || !checksum.chars().all(|char| char.is_ascii_alphanumeric()) { + return Err(ValidationError::InvalidInput( + "descriptor checksum must be 8 alphanumeric characters (shape only)".to_owned(), + )); + } + Ok(()) +} + +fn is_supported_descriptor_prefix(descriptor_body: &str) -> bool { + const SUPPORTED_PREFIXES: [&str; 6] = ["wpkh(", "tr(", "pkh(", "sh(wpkh(", "wsh(", "sh(wsh("]; + SUPPORTED_PREFIXES + .iter() + .any(|prefix| descriptor_body.starts_with(prefix)) +} + +fn has_balanced_parentheses(value: &str) -> bool { + let mut depth = 0usize; + for char in value.chars() { + if char == '(' { + depth += 1; + continue; + } + if char == ')' { + if depth == 0 { + return false; + } + depth -= 1; + } + } + depth == 0 +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs new file mode 100644 index 0000000..2fff25c --- /dev/null +++ b/api/src/routes/mod.rs @@ -0,0 +1 @@ +pub mod wallet; diff --git a/api/src/routes/wallet.rs b/api/src/routes/wallet.rs new file mode 100644 index 0000000..1c23cc4 --- /dev/null +++ b/api/src/routes/wallet.rs @@ -0,0 +1,200 @@ +use axum::{extract::State, routing::post, Json, Router}; +use serde::Deserialize; +use stealth_engine::engine::{AnalysisEngine, EngineSettings, ScanTarget, UtxoInput}; +use stealth_engine::Report; + +use crate::error::ApiError; +use crate::preflight::validate; +use crate::GatewayState; + +pub fn router() -> Router { + Router::new().route("/scan", post(scan_post)) +} + +#[derive(Debug, Deserialize)] +struct ScanRequestBody { + #[serde(default)] + descriptor: Option, + #[serde(default)] + descriptors: Option>, + #[serde(default)] + utxos: Option>, +} + +async fn scan_post( + State(gateway): State, + Json(body): Json, +) -> Result, ApiError> { + let target = body.into_scan_target()?; + let target = validate(target)?; + + let gw = gateway.ok_or(ApiError::ScannerNotConfigured)?; + let report = tokio::task::spawn_blocking(move || { + let engine = AnalysisEngine::new(gw.as_ref(), EngineSettings::default()); + engine.analyze(target) + }) + .await + .map_err(|e| ApiError::Internal(e.to_string()))??; + + Ok(Json(report)) +} + +impl ScanRequestBody { + fn into_scan_target(self) -> Result { + let mut selected_sources = 0usize; + if self.descriptor.is_some() { + selected_sources += 1; + } + if self.descriptors.is_some() { + selected_sources += 1; + } + if self.utxos.is_some() { + selected_sources += 1; + } + + if selected_sources == 0 { + return Err(ApiError::bad_request( + "one input source is required: descriptor, descriptors, or utxos", + )); + } + if selected_sources > 1 { + return Err(ApiError::bad_request( + "descriptor, descriptors, and utxos are mutually exclusive", + )); + } + + if let Some(descriptor) = self.descriptor { + return Ok(ScanTarget::Descriptor(descriptor)); + } + if let Some(descriptors) = self.descriptors { + return Ok(ScanTarget::Descriptors(descriptors)); + } + if let Some(utxos) = self.utxos { + return Ok(ScanTarget::Utxos(utxos)); + } + + Err(ApiError::bad_request("invalid scan request body")) + } +} + +#[cfg(test)] +mod tests { + use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + }; + use serde_json::{json, Value}; + use tower::ServiceExt; + + use crate::app; + + #[tokio::test] + async fn get_scan_is_not_allowed() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/wallet/scan") + .method("GET") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + } + + #[tokio::test] + async fn post_scan_requires_one_input_source() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/wallet/scan") + .method("POST") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = read_json(response).await; + assert_eq!(body["error"]["code"], "bad_request"); + } + + #[tokio::test] + async fn post_scan_rejects_multiple_sources() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/wallet/scan") + .method("POST") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "descriptor": "wpkh(xpub.../0/*)", + "utxos": [ + { + "txid": "0000000000000000000000000000000000000000000000000000000000000001", + "vout": 0 + } + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = read_json(response).await; + assert_eq!(body["error"]["code"], "bad_request"); + } + + #[tokio::test] + async fn post_scan_returns_503_without_rpc_config() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/wallet/scan") + .method("POST") + .header("content-type", "application/json") + .body(Body::from( + json!({ "descriptor": "wpkh(xpub.../0/*)" }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = read_json(response).await; + assert_eq!(body["error"]["code"], "scanner_not_configured"); + } + + #[tokio::test] + async fn post_scan_rejects_invalid_descriptor() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/wallet/scan") + .method("POST") + .header("content-type", "application/json") + .body(Body::from(json!({ "descriptor": "" }).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = read_json(response).await; + assert_eq!(body["error"]["code"], "invalid_scan_input"); + } + + async fn read_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() + } +} diff --git a/api/tests/e2e_regtest_scan.rs b/api/tests/e2e_regtest_scan.rs new file mode 100644 index 0000000..b918510 --- /dev/null +++ b/api/tests/e2e_regtest_scan.rs @@ -0,0 +1,163 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use corepc_node::client::bitcoin::Amount; +use corepc_node::Node; +use reqwest::StatusCode; +use serde_json::{json, Value}; +use stealth_bitcoincore::BitcoinCoreRpc; +use tokio::sync::oneshot; + +#[tokio::test] +async fn scan_descriptor_clean_then_findings_after_regtest_activity() { + let node = start_node(); + let mining_addr = node.client.new_address().unwrap(); + mine(&node, 110, &mining_addr); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + + // Fund bob so it can create payments to alice. + let bob_fund_addr = bob.new_address().unwrap(); + node.client + .send_to_address(&bob_fund_addr, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &mining_addr); + + let reused_addr = alice.new_address().unwrap(); + let descriptor = alice + .get_address_info(&reused_addr) + .unwrap() + .descriptor + .expect("wallet address has no descriptor"); + + let rpc_url = node.rpc_url(); + let cookie = + std::fs::read_to_string(&node.params.cookie_file).expect("failed to read cookie file"); + let gateway = tokio::task::spawn_blocking(move || { + let mut parts = cookie.trim().splitn(2, ':'); + let user = parts.next().unwrap().to_string(); + let pass = parts.next().unwrap().to_string(); + BitcoinCoreRpc::from_url(&rpc_url, Some(user), Some(pass)).expect("failed to build gateway") + }) + .await + .unwrap(); + let server = ApiServer::spawn(gateway).await; + let client = reqwest::Client::new(); + + let first = scan_descriptor(&client, &server, &descriptor).await; + assert_eq!(first.status, StatusCode::OK); + assert_eq!(first.body["summary"]["clean"], Value::Bool(true)); + assert_eq!(first.body["stats"]["transactions_analyzed"], Value::from(0)); + + // Reuse one receive address twice to trigger address-reuse finding. + bob.send_to_address(&reused_addr, Amount::from_sat(1_000_000)) + .unwrap(); + bob.send_to_address(&reused_addr, Amount::from_sat(2_000_000)) + .unwrap(); + mine(&node, 1, &mining_addr); + + let second = scan_descriptor(&client, &server, &descriptor).await; + assert_eq!(second.status, StatusCode::OK); + assert_eq!(second.body["summary"]["clean"], Value::Bool(false)); + assert!( + second.body["summary"]["findings"] + .as_u64() + .unwrap_or_default() + > 0 + ); + assert!( + second.body["stats"]["transactions_analyzed"] + .as_u64() + .unwrap_or_default() + > 0 + ); + + server.stop().await; +} + +fn start_node() -> Node { + let exe = corepc_node::exe_path().expect("bitcoind not found"); + let mut conf = corepc_node::Conf::default(); + conf.args.push("-txindex"); + Node::with_conf(exe, &conf).expect("failed to start regtest node") +} + +fn mine(node: &Node, blocks: usize, addr: &corepc_node::client::bitcoin::Address) { + node.client.generate_to_address(blocks, addr).unwrap(); +} + +async fn scan_descriptor( + client: &reqwest::Client, + server: &ApiServer, + descriptor: &str, +) -> ScanResponse { + let response = client + .post(server.url("/api/wallet/scan")) + .json(&json!({ "descriptor": descriptor })) + .send() + .await + .unwrap(); + let status = response.status(); + let body: Value = response.json().await.unwrap(); + ScanResponse { status, body } +} + +struct ScanResponse { + status: StatusCode, + body: Value, +} + +struct ApiServer { + address: SocketAddr, + shutdown_tx: Option>, + handle: tokio::task::JoinHandle<()>, + /// Held so the gateway outlives the server task; dropped explicitly + /// on a blocking thread to avoid reqwest::blocking runtime panics. + gateway: Option>, +} + +impl ApiServer { + async fn spawn(gateway: BitcoinCoreRpc) -> Self { + let gateway: Arc = + Arc::new(gateway); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let server = axum::serve( + listener, + stealth_api::app_with_gateway(Some(gateway.clone())), + ) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }); + + let handle = tokio::spawn(async move { + let _ = server.await; + }); + + Self { + address, + shutdown_tx: Some(shutdown_tx), + handle, + gateway: Some(gateway), + } + } + + fn url(&self, path_and_query: &str) -> String { + format!("http://{}{}", self.address, path_and_query) + } + + async fn stop(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + let _ = self.handle.await; + // Drop the gateway (reqwest::blocking::Client) on a blocking + // thread so its internal Tokio runtime can shut down safely. + if let Some(gw) = self.gateway.take() { + tokio::task::spawn_blocking(move || drop(gw)).await.ok(); + } + } +} diff --git a/api/tests/http_scan.rs b/api/tests/http_scan.rs new file mode 100644 index 0000000..49157be --- /dev/null +++ b/api/tests/http_scan.rs @@ -0,0 +1,158 @@ +use std::net::SocketAddr; + +use reqwest::StatusCode; +use serde_json::json; +use tokio::sync::oneshot; + +#[tokio::test] +async fn root_path_with_descriptor_is_not_found() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .get(server.url("/?descriptor=123")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + server.stop().await; +} + +#[tokio::test] +async fn scan_get_is_not_allowed() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .get(server.url("/api/wallet/scan?descriptor=wpkh(xpub.../0/*)")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + server.stop().await; +} + +#[tokio::test] +async fn scan_post_with_valid_descriptor_returns_503_without_rpc() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .post(server.url("/api/wallet/scan")) + .json(&json!({ + "descriptor": "wpkh(xpub.../0/*)" + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["error"]["code"], "scanner_not_configured"); + server.stop().await; +} + +#[tokio::test] +async fn scan_post_with_invalid_descriptor_returns_bad_request() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .post(server.url("/api/wallet/scan")) + .json(&json!({ + "descriptor": "123" + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["error"]["code"], "invalid_scan_input"); + server.stop().await; +} + +#[tokio::test] +async fn scan_post_with_descriptors_returns_503_without_rpc() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .post(server.url("/api/wallet/scan")) + .json(&json!({ + "descriptors": ["wpkh(xpub.../0/*)", "wpkh(xpub.../1/*)"] + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["error"]["code"], "scanner_not_configured"); + server.stop().await; +} + +#[tokio::test] +async fn scan_post_rejects_multiple_input_sources() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .post(server.url("/api/wallet/scan")) + .json(&json!({ + "descriptor": "abc", + "utxos": [{ + "txid": "0000000000000000000000000000000000000000000000000000000000000001", + "vout": 0 + }] + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["error"]["code"], "bad_request"); + server.stop().await; +} + +struct TestServer { + address: SocketAddr, + shutdown_tx: Option>, + handle: tokio::task::JoinHandle<()>, +} + +impl TestServer { + async fn spawn() -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let server = axum::serve(listener, stealth_api::app()).with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }); + + let handle = tokio::spawn(async move { + let _ = server.await; + }); + + Self { + address, + shutdown_tx: Some(shutdown_tx), + handle, + } + } + + fn url(&self, path_and_query: &str) -> String { + format!("http://{}{}", self.address, path_and_query) + } + + async fn stop(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + let _ = self.handle.await; + } +} diff --git a/engine/src/engine.rs b/engine/src/engine.rs index af70cf2..0ff40a1 100644 --- a/engine/src/engine.rs +++ b/engine/src/engine.rs @@ -26,12 +26,12 @@ pub use stealth_model::scan::{EngineSettings, ScanTarget, UtxoInput}; /// /// Construct one per request (or per CLI invocation) and call /// [`analyze`](Self::analyze). -pub struct AnalysisEngine<'a, G: BlockchainGateway> { +pub struct AnalysisEngine<'a, G: BlockchainGateway + ?Sized> { gateway: &'a G, settings: EngineSettings, } -impl std::fmt::Debug for AnalysisEngine<'_, G> { +impl std::fmt::Debug for AnalysisEngine<'_, G> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AnalysisEngine") .field("settings", &self.settings) @@ -39,7 +39,7 @@ impl std::fmt::Debug for AnalysisEngine<'_, G> { } } -impl<'a, G: BlockchainGateway> AnalysisEngine<'a, G> { +impl<'a, G: BlockchainGateway + ?Sized> AnalysisEngine<'a, G> { pub fn new(gateway: &'a G, settings: EngineSettings) -> Self { Self { gateway, settings } } diff --git a/model/src/config.rs b/model/src/config.rs index d8a9a5f..6007b09 100644 --- a/model/src/config.rs +++ b/model/src/config.rs @@ -1,24 +1,5 @@ -use std::collections::HashSet; - use bitcoin::Amount; -/// Identifies a specific detector for enable/disable configuration. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DetectorId { - AddressReuse, - Cioh, - Dust, - DustSpending, - ChangeDetection, - Consolidation, - ScriptTypeMixing, - ClusterMerge, - UtxoAgeSpread, - ExchangeOrigin, - TaintedUtxoMerge, - BehavioralFingerprint, -} - /// Numeric thresholds used by the detectors. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DetectorThresholds { @@ -58,7 +39,6 @@ impl Default for DetectorThresholds { pub struct AnalysisConfig { pub derivation_range_end: u32, pub thresholds: DetectorThresholds, - pub enabled_detectors: HashSet, /// Maximum ancestor-fetch depth when resolving UTXO history. /// `0` means only UTXO's own tx; `2` (the default) pub max_ancestor_depth: u32, @@ -69,20 +49,6 @@ impl Default for AnalysisConfig { 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, - ]), max_ancestor_depth: 2, } } diff --git a/model/src/descriptor.rs b/model/src/descriptor.rs index 66a2b26..23c5110 100644 --- a/model/src/descriptor.rs +++ b/model/src/descriptor.rs @@ -12,10 +12,10 @@ pub trait DescriptorNormalizer { /// When a `normalizer` is provided (typically a [`BlockchainGateway`]), /// each candidate is passed through `getdescriptorinfo` for canonical /// checksumming. -pub fn normalize_descriptors( +pub fn normalize_descriptors( raw_descriptors: &[String], derivation_range_end: u32, - normalizer: &dyn DescriptorNormalizer, + normalizer: &N, ) -> Result, AnalysisError> { let mut resolved = Vec::new();