diff --git a/.gitignore b/.gitignore index 8e9d282..7aa9d65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +**/.yarn/install-state.gz .env .env.local *.local diff --git a/Cargo.toml b/Cargo.toml index 7a68c9e..c48c029 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "model", "bitcoincore", "engine", + "api", "cli", ] resolver = "2" diff --git a/README.md b/README.md index 5da96ff..f91c581 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ Stealth ships a Rust workspace with: - `stealth-engine` (analysis engine) - `stealth-model` (domain model types and interfaces) -- `stealth-cli` +- `stealth-api` (http api) +- `stealth-cli` (command-line interface) - `stealth-bitcoincore` (Bitcoin Core RPC gateway adapter) ## Project Direction @@ -167,24 +168,37 @@ cargo build ### 2. Configure Bitcoin Core RPC (regtest) -Copy the example config: +Copy `bitcoin.conf.example` to `bitcoin.conf` and edit the credentials if needed. ```bash cp bitcoin.conf.example bitcoin.conf ``` -### 3. Start regtest and fund a wallet +### 3. Set up regtest (starts node, creates wallet, mines blocks) ```bash ./scripts/setup.sh ``` -This starts `bitcoind` in regtest mode, creates a wallet, mines initial blocks, -and prints the descriptor and a ready-to-use `stealth-cli` command. - Use `./scripts/setup.sh --fresh` to wipe the chain and start from genesis. -### 4. Run a CLI scan +### 4. Start the API + +```bash +cargo run --bin stealth-api +``` + +`stealth-api` auto-detects common local RPC ports and can use credentials from `bitcoin.conf`, cookie file, or env vars. + +### 5. Scan (in another terminal) + +```bash +curl -s 'http://localhost:20899/api/wallet/scan' \ + -H 'content-type: application/json' \ + -d '{"descriptor":""}' | jq +``` + +### 6. Alternative: CLI scan ```bash cargo run --bin stealth-cli -- scan \ @@ -194,7 +208,7 @@ cargo run --bin stealth-cli -- scan \ --format text ``` -### 5. Start frontend +### 7. Start frontend ```bash cd frontend @@ -231,6 +245,10 @@ stealth/ │ │ ├── config.ini # Connection config (datadir, network) │ │ └── bitcoin-data/ # Regtest chain data (gitignored) │ └── src/StealthBackend/ # Quarkus Java REST API (single /api/wallet/scan endpoint) +├── slides/ # Slidev pitch presentation +├── api/ # stealth-api (Axum HTTP layer) +│ ├── src/ +│ └── tests/ ├── cli/ # stealth-cli ├── scripts/ # Development helper scripts (setup.sh) └── target/ # Cargo build outputs diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..bd6ee3c --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "stealth-api" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "HTTP transport for Stealth wallet privacy analysis" +categories = ["cryptography::cryptocurrencies", "web-programming::http-server"] +keywords = ["bitcoin", "privacy", "api", "wallet"] +readme = "README.md" + +[dependencies] +axum = { workspace = true } +ini = { package = "rust-ini", version = "0.21.3" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +stealth-bitcoincore = { path = "../bitcoincore" } +stealth-engine = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tower-http = { version = "0.6.6", features = ["cors"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +corepc-node = { workspace = true } +http-body-util = "0.1.3" +reqwest = { version = "0.12.9", default-features = false, features = ["json", "rustls-tls"] } +tower = { version = "0.5.2", features = ["util"] } diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..0586d6b --- /dev/null +++ b/api/README.md @@ -0,0 +1,107 @@ +# Stealth API + +`stealth-api` is the Rust HTTP transport layer for Stealth. It connects to a +running `bitcoind` via JSON-RPC, imports descriptors into temporary wallets, +builds a transaction graph, and runs privacy detectors from +`stealth-engine`. + +## Running + +```bash +# Stop any old API process, then start the current source build +pkill -f 'target/debug/stealth-api' 2>/dev/null || true + +# Auto-detects local bitcoind RPC port (prefers 18443, then 8332/18332/38332) +# and uses credentials from bitcoin.conf or local cookie files. +cargo run --bin stealth-api +``` + +Set auth explicitly with username/password: + +```bash +STEALTH_RPC_URL=http://127.0.0.1:8332 \ +STEALTH_RPC_USER=user \ +STEALTH_RPC_PASS=pass \ + cargo run --bin stealth-api +``` + +Or use a cookie file: + +```bash +STEALTH_RPC_URL=http://127.0.0.1:8332 \ +STEALTH_RPC_COOKIE=~/.bitcoin/.cookie \ + cargo run --bin stealth-api +``` + +Configure the listen address with `STEALTH_API_BIND` (default `127.0.0.1:20899`). + +If you see `Connection refused (os error 111)`, either: +1. an old `stealth-api` process is still running, or +2. `bitcoind` RPC is not reachable on the detected/configured URL. + +## API + +### `POST /api/wallet/scan` + +Accepts one mutually-exclusive source: + +| Field | Type | Description | +|-------|------|-------------| +| `descriptor` | `string` | Single output descriptor | +| `descriptors` | `string[]` | Multiple descriptors | +| `utxos` | `UtxoInput[]` | Raw UTXO set | + +**Descriptor scan flow:** creates a blank watch-only wallet, imports the +descriptor(s) with a full blockchain rescan, builds a `TxGraph`, runs all +17 detectors, then cleans up the temporary wallet. + +**UTXO scan flow:** resolves each UTXO's address from the node, builds a +partial transaction graph, and runs applicable detectors. + +#### Example (real descriptor from Bitcoin Core) + +```bash +RPC="bitcoin-cli -regtest -rpcport=18443 -rpcuser=localuser -rpcpassword=localpass" +WALLET="scanwallet_$(date +%s)" + +$RPC createwallet "$WALLET" >/dev/null +ADDR="$($RPC -rpcwallet="$WALLET" getnewaddress)" +DESC="$($RPC -rpcwallet="$WALLET" getaddressinfo "$ADDR" | jq -r '.desc')" + +curl 'http://localhost:20899/api/wallet/scan' \ + -H 'content-type: application/json' \ + -d "{\"descriptor\":\"$DESC\"}" | jq +``` + +#### Responses + +| Status | Meaning | +|--------|---------| +| `200` | Scan completed — body is a `Report` | +| `400` | Invalid input (bad descriptor shape, empty UTXOs, …) | +| `502` | bitcoind RPC unavailable/auth failed/connection failed | + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `STEALTH_API_BIND` | Listen address (default `127.0.0.1:20899`) | +| `STEALTH_RPC_URL` | bitcoind RPC endpoint (overrides auto-detection) | +| `STEALTH_RPC_USER` | RPC username (otherwise read from `bitcoin.conf` when available) | +| `STEALTH_RPC_PASS` | RPC password (otherwise read from `bitcoin.conf` when available) | +| `STEALTH_RPC_COOKIE` | Path to `.cookie` file (otherwise API auto-detects common local cookie locations) | + +## E2E test (regtest) + +The API includes an end-to-end regtest integration test that: +1. creates wallets, +2. gets a real descriptor from `bitcoind`, +3. scans once with no history (`summary.clean = true`), +4. creates/mine transactions, +5. scans again and asserts findings (`summary.clean = false`). + +Run it with: + +```bash +cargo test -p stealth-api scan_descriptor_clean_then_findings_after_regtest_activity -- --nocapture +``` diff --git a/api/src/error.rs b/api/src/error.rs new file mode 100644 index 0000000..c0e49e2 --- /dev/null +++ b/api/src/error.rs @@ -0,0 +1,73 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use thiserror::Error; + +use stealth_engine::error::AnalysisError; + +#[derive(Debug, Error)] +pub enum ApiError { + #[error("{0}")] + BadRequest(String), + #[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) -> Self { + Self::BadRequest(message.into()) + } + + fn status_code(&self) -> StatusCode { + match self { + Self::BadRequest(_) => 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::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, +} diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 0000000..b8d0433 --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,24 @@ +mod error; +mod routes; + +use std::sync::Arc; + +use axum::Router; +use stealth_engine::gateway::BlockchainGateway; +use tower_http::cors::CorsLayer; + +/// Shared application state: an optional blockchain gateway. +pub type GatewayState = Option>; + +/// 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()) + .layer(CorsLayer::permissive()) + .with_state(gateway) +} diff --git a/api/src/main.rs b/api/src/main.rs new file mode 100644 index 0000000..124c6a9 --- /dev/null +++ b/api/src/main.rs @@ -0,0 +1,180 @@ +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use ini::Ini; +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> { + init_tracing(); + + // `BitcoinCoreRpc` uses `reqwest::blocking::Client`, which must be + // constructed outside a Tokio runtime. + let gateway: Arc = 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, +) -> 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 { + let raw = std::env::var("STEALTH_API_BIND").unwrap_or_else(|_| "127.0.0.1:20899".to_owned()); + raw.parse::() + .map_err(|_| format!("invalid STEALTH_API_BIND value: {raw}")) +} + +fn build_gateway() -> Result> { + 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::().ok()?; + Some((host.to_owned(), port)) +} + +fn detect_cookie_file(url: &str) -> Option { + 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); + + cookie_candidates(&bitcoin_dir, port) + .into_iter() + .find(|candidate| candidate.exists()) +} + +fn cookie_candidates(bitcoin_dir: &Path, port: u16) -> Vec { + 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 = Ini::load_from_file("bitcoin.conf").ok()?; + let try_section = |props: &ini::Properties| -> Option<(String, String)> { + let user = props.get("rpcuser")?; + let pass = props.get("rpcpassword")?; + Some((user.to_owned(), pass.to_owned())) + }; + try_section(conf.general_section()) + .or_else(|| conf.section(Some("regtest")).and_then(try_section)) + .or_else(|| conf.section(Some("test")).and_then(try_section)) + .or_else(|| conf.section(Some("signet")).and_then(try_section)) + .or_else(|| conf.section(Some("main")).and_then(try_section)) +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs new file mode 100644 index 0000000..2fff25c --- /dev/null +++ b/api/src/routes/mod.rs @@ -0,0 +1 @@ +pub mod wallet; diff --git a/api/src/routes/wallet.rs b/api/src/routes/wallet.rs new file mode 100644 index 0000000..2239a0b --- /dev/null +++ b/api/src/routes/wallet.rs @@ -0,0 +1,195 @@ +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::GatewayState; + +pub fn router() -> Router { + Router::new().route("/scan", post(scan_post)) +} + +#[derive(Debug, Deserialize)] +struct ScanRequestBody { + #[serde(default)] + descriptor: Option, + #[serde(default)] + descriptors: Option>, + #[serde(default)] + utxos: Option>, +} + +async fn scan_post( + State(gateway): State, + Json(body): Json, +) -> Result, ApiError> { + let target = body.into_scan_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 { + match (self.descriptor, self.descriptors, self.utxos) { + (Some(d), None, None) => Ok(ScanTarget::Descriptor(d)), + (None, Some(ds), None) if !ds.is_empty() => Ok(ScanTarget::Descriptors(ds)), + (None, Some(_), None) => Err(ApiError::bad_request("`descriptors` must not be empty")), + (None, None, Some(utxos)) if !utxos.is_empty() => Ok(ScanTarget::Utxos(utxos)), + (None, None, Some(_)) => Err(ApiError::bad_request("`utxos` must not be empty")), + (None, None, None) => Err(ApiError::bad_request( + "one input source is required: descriptor, descriptors, or utxos", + )), + _ => Err(ApiError::bad_request("provide exactly one input source")), + } + } +} + +#[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_rejects_empty_descriptors_list() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/wallet/scan") + .method("POST") + .header("content-type", "application/json") + .body(Body::from(json!({ "descriptors": [] }).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_rejects_empty_utxos_list() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/wallet/scan") + .method("POST") + .header("content-type", "application/json") + .body(Body::from(json!({ "utxos": [] }).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"); + } + + 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/e2e_regtest_scan.rs b/api/tests/e2e_regtest_scan.rs new file mode 100644 index 0000000..04608bf --- /dev/null +++ b/api/tests/e2e_regtest_scan.rs @@ -0,0 +1,160 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use corepc_node::client::bitcoin::Amount; +use corepc_node::Node; +use reqwest::StatusCode; +use serde_json::{json, Value}; +use stealth_bitcoincore::{read_cookie_file, BitcoinCoreRpc}; +use tokio::sync::oneshot; + +#[tokio::test] +async fn scan_descriptor_clean_then_findings_after_regtest_activity() { + let node = start_node(); + let mining_addr = node.client.new_address().unwrap(); + mine(&node, 110, &mining_addr); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + + // Fund bob so it can create payments to alice. + let bob_fund_addr = bob.new_address().unwrap(); + node.client + .send_to_address(&bob_fund_addr, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &mining_addr); + + let reused_addr = alice.new_address().unwrap(); + let descriptor = alice + .get_address_info(&reused_addr) + .unwrap() + .descriptor + .expect("wallet address has no descriptor"); + + let rpc_url = node.rpc_url(); + let cookie_path = node.params.cookie_file.clone(); + let gateway = tokio::task::spawn_blocking(move || { + let (user, pass) = read_cookie_file(&cookie_path).expect("failed to read cookie file"); + BitcoinCoreRpc::from_url(&rpc_url, Some(user), Some(pass)).expect("failed to build gateway") + }) + .await + .unwrap(); + let server = ApiServer::spawn(gateway).await; + let client = reqwest::Client::new(); + + let first = scan_descriptor(&client, &server, &descriptor).await; + assert_eq!(first.status, StatusCode::OK); + assert_eq!(first.body["summary"]["clean"], Value::Bool(true)); + assert_eq!(first.body["stats"]["transactions_analyzed"], Value::from(0)); + + // Reuse one receive address twice to trigger address-reuse finding. + bob.send_to_address(&reused_addr, Amount::from_sat(1_000_000)) + .unwrap(); + bob.send_to_address(&reused_addr, Amount::from_sat(2_000_000)) + .unwrap(); + mine(&node, 1, &mining_addr); + + let second = scan_descriptor(&client, &server, &descriptor).await; + assert_eq!(second.status, StatusCode::OK); + assert_eq!(second.body["summary"]["clean"], Value::Bool(false)); + assert!( + second.body["summary"]["findings"] + .as_u64() + .unwrap_or_default() + > 0 + ); + assert!( + second.body["stats"]["transactions_analyzed"] + .as_u64() + .unwrap_or_default() + > 0 + ); + + server.stop().await; +} + +fn start_node() -> Node { + let exe = corepc_node::exe_path().expect("bitcoind not found"); + let mut conf = corepc_node::Conf::default(); + conf.args.push("-txindex"); + Node::with_conf(exe, &conf).expect("failed to start regtest node") +} + +fn mine(node: &Node, blocks: usize, addr: &corepc_node::client::bitcoin::Address) { + node.client.generate_to_address(blocks, addr).unwrap(); +} + +async fn scan_descriptor( + client: &reqwest::Client, + server: &ApiServer, + descriptor: &str, +) -> ScanResponse { + let response = client + .post(server.url("/api/wallet/scan")) + .json(&json!({ "descriptor": descriptor })) + .send() + .await + .unwrap(); + let status = response.status(); + let body: Value = response.json().await.unwrap(); + ScanResponse { status, body } +} + +struct ScanResponse { + status: StatusCode, + body: Value, +} + +struct ApiServer { + address: SocketAddr, + shutdown_tx: Option>, + handle: tokio::task::JoinHandle<()>, + /// Held so the gateway outlives the server task; dropped explicitly + /// on a blocking thread to avoid reqwest::blocking runtime panics. + gateway: Option>, +} + +impl ApiServer { + async fn spawn(gateway: BitcoinCoreRpc) -> Self { + let gateway: Arc = + Arc::new(gateway); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let server = axum::serve( + listener, + stealth_api::app_with_gateway(Some(gateway.clone())), + ) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }); + + let handle = tokio::spawn(async move { + let _ = server.await; + }); + + Self { + address, + shutdown_tx: Some(shutdown_tx), + handle, + gateway: Some(gateway), + } + } + + fn url(&self, path_and_query: &str) -> String { + format!("http://{}{}", self.address, path_and_query) + } + + async fn stop(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + let _ = self.handle.await; + // Drop the gateway (reqwest::blocking::Client) on a blocking + // thread so its internal Tokio runtime can shut down safely. + if let Some(gw) = self.gateway.take() { + tokio::task::spawn_blocking(move || drop(gw)).await.ok(); + } + } +} diff --git a/api/tests/http_scan.rs b/api/tests/http_scan.rs new file mode 100644 index 0000000..e382e5d --- /dev/null +++ b/api/tests/http_scan.rs @@ -0,0 +1,138 @@ +use std::net::SocketAddr; + +use reqwest::StatusCode; +use serde_json::json; +use tokio::sync::oneshot; + +#[tokio::test] +async fn root_path_with_descriptor_is_not_found() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .get(server.url("/?descriptor=123")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + server.stop().await; +} + +#[tokio::test] +async fn scan_get_is_not_allowed() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .get(server.url("/api/wallet/scan?descriptor=wpkh(xpub.../0/*)")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + server.stop().await; +} + +#[tokio::test] +async fn scan_post_with_valid_descriptor_returns_503_without_rpc() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .post(server.url("/api/wallet/scan")) + .json(&json!({ + "descriptor": "wpkh(xpub.../0/*)" + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["error"]["code"], "scanner_not_configured"); + server.stop().await; +} + +#[tokio::test] +async fn scan_post_with_descriptors_returns_503_without_rpc() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .post(server.url("/api/wallet/scan")) + .json(&json!({ + "descriptors": ["wpkh(xpub.../0/*)", "wpkh(xpub.../1/*)"] + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["error"]["code"], "scanner_not_configured"); + server.stop().await; +} + +#[tokio::test] +async fn scan_post_rejects_multiple_input_sources() { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .post(server.url("/api/wallet/scan")) + .json(&json!({ + "descriptor": "abc", + "utxos": [{ + "txid": "0000000000000000000000000000000000000000000000000000000000000001", + "vout": 0 + }] + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["error"]["code"], "bad_request"); + server.stop().await; +} + +struct TestServer { + address: SocketAddr, + shutdown_tx: Option>, + handle: tokio::task::JoinHandle<()>, +} + +impl TestServer { + async fn spawn() -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let server = axum::serve(listener, stealth_api::app()).with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }); + + let handle = tokio::spawn(async move { + let _ = server.await; + }); + + Self { + address, + shutdown_tx: Some(shutdown_tx), + handle, + } + } + + fn url(&self, path_and_query: &str) -> String { + format!("http://{}{}", self.address, path_and_query) + } + + async fn stop(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + let _ = self.handle.await; + } +} diff --git a/engine/src/detect.rs b/engine/src/detect.rs index 40a446e..de1324f 100644 --- a/engine/src/detect.rs +++ b/engine/src/detect.rs @@ -586,7 +586,7 @@ impl TxGraph { } let mut aged: Vec<_> = our_utxos.iter().map(|u| (u, u.confirmations)).collect(); - aged.sort_by(|a, b| b.1.cmp(&a.1)); + aged.sort_by_key(|b| std::cmp::Reverse(b.1)); let oldest = aged.first().unwrap(); let newest = aged.last().unwrap(); diff --git a/engine/src/engine.rs b/engine/src/engine.rs index af70cf2..0ff40a1 100644 --- a/engine/src/engine.rs +++ b/engine/src/engine.rs @@ -26,12 +26,12 @@ pub use stealth_model::scan::{EngineSettings, ScanTarget, UtxoInput}; /// /// Construct one per request (or per CLI invocation) and call /// [`analyze`](Self::analyze). -pub struct AnalysisEngine<'a, G: BlockchainGateway> { +pub struct AnalysisEngine<'a, G: BlockchainGateway + ?Sized> { gateway: &'a G, settings: EngineSettings, } -impl std::fmt::Debug for AnalysisEngine<'_, G> { +impl std::fmt::Debug for AnalysisEngine<'_, G> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AnalysisEngine") .field("settings", &self.settings) @@ -39,7 +39,7 @@ impl std::fmt::Debug for AnalysisEngine<'_, G> { } } -impl<'a, G: BlockchainGateway> AnalysisEngine<'a, G> { +impl<'a, G: BlockchainGateway + ?Sized> AnalysisEngine<'a, G> { pub fn new(gateway: &'a G, settings: EngineSettings) -> Self { Self { gateway, settings } } diff --git a/frontend/.yarn/install-state.gz b/frontend/.yarn/install-state.gz deleted file mode 100644 index 4b043e5..0000000 Binary files a/frontend/.yarn/install-state.gz and /dev/null differ diff --git a/frontend/src/services/walletService.js b/frontend/src/services/walletService.js index 7381368..d596397 100644 --- a/frontend/src/services/walletService.js +++ b/frontend/src/services/walletService.js @@ -1,5 +1,9 @@ export const analyzeWallet = async (descriptor) => { - const res = await fetch(`/api/wallet/scan?descriptor=${encodeURIComponent(descriptor)}`) + const res = await fetch('/api/wallet/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ descriptor }), + }) if (!res.ok) throw new Error('Analysis failed') return res.json() } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index caec31c..7bc506e 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,7 +5,7 @@ export default defineConfig({ plugins: [react()], server: { proxy: { - '/api': 'http://localhost:8080' + '/api': 'http://localhost:20899' } } }) diff --git a/model/src/descriptor.rs b/model/src/descriptor.rs index 66a2b26..23c5110 100644 --- a/model/src/descriptor.rs +++ b/model/src/descriptor.rs @@ -12,10 +12,10 @@ pub trait DescriptorNormalizer { /// When a `normalizer` is provided (typically a [`BlockchainGateway`]), /// each candidate is passed through `getdescriptorinfo` for canonical /// checksumming. -pub fn normalize_descriptors( +pub fn normalize_descriptors( raw_descriptors: &[String], derivation_range_end: u32, - normalizer: &dyn DescriptorNormalizer, + normalizer: &N, ) -> Result, AnalysisError> { let mut resolved = Vec::new();