mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-06-10 06:13:31 -07:00
refactor(api): expand and test api as stealth http interface
This commit is contained in:
@@ -3,6 +3,7 @@ members = [
|
||||
"model",
|
||||
"bitcoincore",
|
||||
"engine",
|
||||
"api",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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<String>) -> 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,
|
||||
}
|
||||
@@ -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<Arc<dyn BlockchainGateway + Send + Sync>>;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
+209
@@ -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<dyn std::error::Error>> {
|
||||
init_tracing();
|
||||
|
||||
// `BitcoinCoreRpc` uses `reqwest::blocking::Client`, which must be
|
||||
// constructed outside a Tokio runtime.
|
||||
let gateway: Arc<dyn BlockchainGateway + Send + Sync> = 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<dyn BlockchainGateway + Send + Sync>,
|
||||
) -> 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<SocketAddr, String> {
|
||||
let raw = std::env::var("STEALTH_API_BIND").unwrap_or_else(|_| "127.0.0.1:20899".to_owned());
|
||||
raw.parse::<SocketAddr>()
|
||||
.map_err(|_| format!("invalid STEALTH_API_BIND value: {raw}"))
|
||||
}
|
||||
|
||||
fn build_gateway() -> Result<BitcoinCoreRpc, Box<dyn std::error::Error>> {
|
||||
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::<u16>().ok()?;
|
||||
Some((host.to_owned(), port))
|
||||
}
|
||||
|
||||
fn detect_cookie_file(url: &str) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String> = None;
|
||||
let mut pass: Option<String> = 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,
|
||||
}
|
||||
}
|
||||
@@ -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<ScanTarget, ValidationError> {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod wallet;
|
||||
@@ -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<GatewayState> {
|
||||
Router::new().route("/scan", post(scan_post))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScanRequestBody {
|
||||
#[serde(default)]
|
||||
descriptor: Option<String>,
|
||||
#[serde(default)]
|
||||
descriptors: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
utxos: Option<Vec<UtxoInput>>,
|
||||
}
|
||||
|
||||
async fn scan_post(
|
||||
State(gateway): State<GatewayState>,
|
||||
Json(body): Json<ScanRequestBody>,
|
||||
) -> Result<Json<Report>, 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<ScanTarget, ApiError> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<oneshot::Sender<()>>,
|
||||
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<Arc<dyn stealth_engine::gateway::BlockchainGateway + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ApiServer {
|
||||
async fn spawn(gateway: BitcoinCoreRpc) -> Self {
|
||||
let gateway: Arc<dyn stealth_engine::gateway::BlockchainGateway + Send + Sync> =
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<oneshot::Sender<()>>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<G: BlockchainGateway> std::fmt::Debug for AnalysisEngine<'_, G> {
|
||||
impl<G: BlockchainGateway + ?Sized> 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<G: BlockchainGateway> 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 }
|
||||
}
|
||||
|
||||
@@ -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<DetectorId>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<N: DescriptorNormalizer + ?Sized>(
|
||||
raw_descriptors: &[String],
|
||||
derivation_range_end: u32,
|
||||
normalizer: &dyn DescriptorNormalizer,
|
||||
normalizer: &N,
|
||||
) -> Result<Vec<ResolvedDescriptor>, AnalysisError> {
|
||||
let mut resolved = Vec::new();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user