refactor(api): expand and test api as stealth http interface

This commit is contained in:
Renato Britto
2026-04-05 20:16:24 -03:00
parent 1b06b64d98
commit 720342f880
13 changed files with 1001 additions and 39 deletions
+1
View File
@@ -3,6 +3,7 @@ members = [
"model",
"bitcoincore",
"engine",
"api",
]
resolver = "2"
+29
View File
@@ -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"] }
+77
View File
@@ -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,
}
+23
View File
@@ -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
View File
@@ -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,
}
}
+135
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
pub mod wallet;
+200
View File
@@ -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()
}
}
+163
View File
@@ -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();
}
}
}
+158
View File
@@ -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;
}
}
+3 -3
View File
@@ -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 }
}
-34
View File
@@ -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,
}
}
+2 -2
View File
@@ -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();