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
+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()
}
}