feat: add rust analysis workspace

This commit is contained in:
Breno Brito
2026-03-17 14:53:17 -03:00
parent 5205133638
commit 9792b231ba
21 changed files with 5422 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Parser;
use stealth_app::{build_router, build_runtime_service, default_bitcoin_config_path};
use stealth_core::engine::EngineSettings;
#[derive(Debug, Parser)]
struct ApiCli {
#[arg(long, default_value = "0.0.0.0")]
host: String,
#[arg(long, default_value_t = 8080)]
port: u16,
#[arg(long, default_value = "http://localhost:5173")]
cors_origin: String,
#[arg(long, default_value_os_t = default_bitcoin_config_path())]
config: PathBuf,
#[arg(long = "known-risky-wallet")]
known_risky_wallets: Vec<String>,
#[arg(long = "known-exchange-wallet")]
known_exchange_wallets: Vec<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = ApiCli::parse();
let settings = EngineSettings {
known_exchange_wallets: cli.known_exchange_wallets,
known_risky_wallets: cli.known_risky_wallets,
..EngineSettings::default()
};
let service = build_runtime_service(&cli.config, settings)?;
let router = build_router(Arc::new(service), Some(&cli.cors_origin));
let addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;
Ok(())
}
+189
View File
@@ -0,0 +1,189 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::{HeaderValue, Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::{Json, Router, routing::get};
use serde::{Deserialize, Serialize};
use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc};
use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget};
use stealth_core::error::AnalysisError;
use stealth_core::gateway::BlockchainGateway;
use stealth_core::model::AnalysisReport;
use tower_http::cors::CorsLayer;
pub trait ScanService: Send + Sync + 'static {
fn analyze_descriptor(&self, descriptor: String) -> Result<AnalysisReport, AnalysisError>;
}
pub struct CoreScanService<G> {
gateway: G,
settings: EngineSettings,
}
impl<G> CoreScanService<G> {
pub fn new(gateway: G, settings: EngineSettings) -> Self {
Self { gateway, settings }
}
}
impl<G> ScanService for CoreScanService<G>
where
G: BlockchainGateway + Send + Sync + 'static,
{
fn analyze_descriptor(&self, descriptor: String) -> Result<AnalysisReport, AnalysisError> {
AnalysisEngine::new(&self.gateway, self.settings.clone())
.analyze(ScanTarget::Descriptors(vec![descriptor]))
}
}
pub fn default_bitcoin_config_path() -> PathBuf {
PathBuf::from("backend/script/config.ini")
}
pub fn build_runtime_service(
config_path: &Path,
settings: EngineSettings,
) -> Result<CoreScanService<BitcoinCoreRpc>, AnalysisError> {
let config = BitcoinCoreConfig::from_ini_file(config_path)?;
let gateway = BitcoinCoreRpc::new(config)?;
Ok(CoreScanService::new(gateway, settings))
}
pub fn build_router<S>(service: Arc<S>, cors_origin: Option<&str>) -> Router
where
S: ScanService,
{
let mut router = Router::new()
.route("/api/wallet/scan", get(scan_handler::<S>))
.with_state(service);
if let Some(origin) = cors_origin {
if let Ok(header_value) = HeaderValue::from_str(origin) {
router = router.layer(
CorsLayer::new()
.allow_origin(header_value)
.allow_methods([Method::GET])
.allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::ACCEPT]),
);
}
}
router
}
#[derive(Debug, Deserialize)]
struct ScanQuery {
descriptor: Option<String>,
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
async fn scan_handler<S>(State(service): State<Arc<S>>, Query(query): Query<ScanQuery>) -> Response
where
S: ScanService,
{
let Some(descriptor) = query.descriptor.map(|value| value.trim().to_string()) else {
return json_error(
StatusCode::BAD_REQUEST,
"descriptor query parameter is required".into(),
);
};
if descriptor.is_empty() {
return json_error(
StatusCode::BAD_REQUEST,
"descriptor query parameter is required".into(),
);
}
match service.analyze_descriptor(descriptor) {
Ok(report) => Json(report).into_response(),
Err(error) => map_error(error),
}
}
fn map_error(error: AnalysisError) -> Response {
match error {
AnalysisError::EmptyDescriptor | AnalysisError::DescriptorNormalization { .. } => {
json_error(StatusCode::BAD_REQUEST, error.to_string())
}
AnalysisError::AnalysisEmpty => json_error(StatusCode::NOT_FOUND, error.to_string()),
AnalysisError::EnvironmentUnavailable(_) => {
json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
}
}
}
fn json_error(status: StatusCode, message: String) -> Response {
(status, Json(ErrorBody { error: message })).into_response()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use serde_json::json;
use tower::util::ServiceExt;
use super::*;
struct MockService(Result<AnalysisReport, AnalysisError>);
impl ScanService for MockService {
fn analyze_descriptor(&self, _descriptor: String) -> Result<AnalysisReport, AnalysisError> {
self.0.clone()
}
}
#[tokio::test]
async fn returns_400_for_missing_descriptor() {
let app = build_router(
Arc::new(MockService(Ok(AnalysisReport::new(
0,
0,
Vec::new(),
Vec::new(),
)))),
None,
);
let response = app
.oneshot(
Request::builder()
.uri("/api/wallet/scan")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn returns_json_report_for_successful_scan() {
let report = AnalysisReport::new(1, 2, Vec::new(), Vec::new());
let app = build_router(Arc::new(MockService(Ok(report))), None);
let response = app
.oneshot(
Request::builder()
.uri("/api/wallet/scan?descriptor=wpkh(test)")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json = serde_json::from_slice::<serde_json::Value>(&body).unwrap();
assert_eq!(json["summary"]["clean"], json!(true));
}
}
+48
View File
@@ -0,0 +1,48 @@
use std::path::PathBuf;
use clap::Parser;
use stealth_app::default_bitcoin_config_path;
use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc};
use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget};
#[derive(Debug, Parser)]
struct Cli {
#[arg(long = "descriptor", short = 'd')]
descriptors: Vec<String>,
#[arg(long)]
wallet: Option<String>,
#[arg(long, default_value_os_t = default_bitcoin_config_path())]
config: PathBuf,
#[arg(long = "known-risky-wallet")]
known_risky_wallets: Vec<String>,
#[arg(long = "known-exchange-wallet")]
known_exchange_wallets: Vec<String>,
#[arg(long)]
pretty: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let settings = EngineSettings {
known_exchange_wallets: cli.known_exchange_wallets,
known_risky_wallets: cli.known_risky_wallets,
..EngineSettings::default()
};
let config = BitcoinCoreConfig::from_ini_file(&cli.config)?;
let gateway = BitcoinCoreRpc::new(config)?;
let engine = AnalysisEngine::new(&gateway, settings);
let report = if let Some(wallet_name) = cli.wallet {
engine.analyze(ScanTarget::WalletName(wallet_name))?
} else {
engine.analyze(ScanTarget::Descriptors(cli.descriptors))?
};
if cli.pretty {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("{}", serde_json::to_string(&report)?);
}
Ok(())
}