mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-06-09 22:13:29 -07:00
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:
+1
-5
@@ -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,5 +1,4 @@
|
||||
mod error;
|
||||
mod preflight;
|
||||
mod routes;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user