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
This commit is contained in:
LORDBABUINO
2026-05-11 20:21:23 -03:00
parent 2dcb2d244c
commit 25a0594f74
5 changed files with 1 additions and 182 deletions
+1 -5
View File
@@ -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",
-1
View File
@@ -1,5 +1,4 @@
mod error;
mod preflight;
mod routes;
use std::sync::Arc;
-135
View File
@@ -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<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
}
-21
View File
@@ -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<GatewayState> {
@@ -26,7 +25,6 @@ async fn scan_post(
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 || {
@@ -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()
-20
View File
@@ -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;