From 25a0594f747575bc0ffad58b39c0f07e69e04d94 Mon Sep 17 00:00:00 2001 From: LORDBABUINO Date: Mon, 11 May 2026 20:21:23 -0300 Subject: [PATCH] refactor(api): remove preflight descriptor validation The Bitcoin Core node is the authority on descriptor validity. Reimplementing descriptor shape/prefix/checksum checks here creates a divergence risk with the node's own validator and adds maintenance surface for no real safety gain. Invalid descriptors now fail at the node, surfaced through AnalysisError. - delete api/src/preflight.rs and its module declaration - drop ValidationError from ApiError (no remaining producers) - remove the validate() call from the scan handler - drop the two tests that asserted preflight-specific rejection codes --- api/src/error.rs | 6 +- api/src/lib.rs | 1 - api/src/preflight.rs | 135 --------------------------------------- api/src/routes/wallet.rs | 21 ------ api/tests/http_scan.rs | 20 ------ 5 files changed, 1 insertion(+), 182 deletions(-) delete mode 100644 api/src/preflight.rs diff --git a/api/src/error.rs b/api/src/error.rs index ddac98e..c0e49e2 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -6,15 +6,12 @@ use axum::{ 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")] @@ -30,7 +27,7 @@ impl ApiError { fn status_code(&self) -> StatusCode { match self { - Self::BadRequest(_) | Self::Validation(_) => StatusCode::BAD_REQUEST, + Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::Analysis(AnalysisError::EmptyDescriptor) | Self::Analysis(AnalysisError::DescriptorNormalization { .. }) => { StatusCode::BAD_REQUEST @@ -45,7 +42,6 @@ impl ApiError { 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", diff --git a/api/src/lib.rs b/api/src/lib.rs index efab047..b8d0433 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,5 +1,4 @@ mod error; -mod preflight; mod routes; use std::sync::Arc; diff --git a/api/src/preflight.rs b/api/src/preflight.rs deleted file mode 100644 index 2553de0..0000000 --- a/api/src/preflight.rs +++ /dev/null @@ -1,135 +0,0 @@ -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/wallet.rs b/api/src/routes/wallet.rs index 10d817a..0a6a6a9 100644 --- a/api/src/routes/wallet.rs +++ b/api/src/routes/wallet.rs @@ -4,7 +4,6 @@ use stealth_engine::engine::{AnalysisEngine, EngineSettings, ScanTarget, UtxoInp use stealth_engine::Report; use crate::error::ApiError; -use crate::preflight::validate; use crate::GatewayState; pub fn router() -> Router { @@ -26,7 +25,6 @@ async fn scan_post( 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 || { @@ -150,25 +148,6 @@ mod tests { 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/http_scan.rs b/api/tests/http_scan.rs index 49157be..e382e5d 100644 --- a/api/tests/http_scan.rs +++ b/api/tests/http_scan.rs @@ -54,26 +54,6 @@ async fn scan_post_with_valid_descriptor_returns_503_without_rpc() { 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;