mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-06-11 06:43:31 -07:00
Merge pull request #17 from satsfy/add-api
refactor: implement stealth api as rust crate
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
**/.yarn/install-state.gz
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
|
||||
@@ -3,6 +3,7 @@ members = [
|
||||
"model",
|
||||
"bitcoincore",
|
||||
"engine",
|
||||
"api",
|
||||
"cli",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -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":"<descriptor from setup.sh output>"}' | 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
|
||||
|
||||
@@ -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"] }
|
||||
+107
@@ -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
|
||||
```
|
||||
@@ -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<String>) -> 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,
|
||||
}
|
||||
@@ -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<Arc<dyn BlockchainGateway + Send + Sync>>;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
+180
@@ -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<dyn std::error::Error>> {
|
||||
init_tracing();
|
||||
|
||||
// `BitcoinCoreRpc` uses `reqwest::blocking::Client`, which must be
|
||||
// constructed outside a Tokio runtime.
|
||||
let gateway: Arc<dyn BlockchainGateway + Send + Sync> = 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<dyn BlockchainGateway + Send + Sync>,
|
||||
) -> 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<SocketAddr, String> {
|
||||
let raw = std::env::var("STEALTH_API_BIND").unwrap_or_else(|_| "127.0.0.1:20899".to_owned());
|
||||
raw.parse::<SocketAddr>()
|
||||
.map_err(|_| format!("invalid STEALTH_API_BIND value: {raw}"))
|
||||
}
|
||||
|
||||
fn build_gateway() -> Result<BitcoinCoreRpc, Box<dyn std::error::Error>> {
|
||||
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::<u16>().ok()?;
|
||||
Some((host.to_owned(), port))
|
||||
}
|
||||
|
||||
fn detect_cookie_file(url: &str) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod wallet;
|
||||
@@ -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<GatewayState> {
|
||||
Router::new().route("/scan", post(scan_post))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScanRequestBody {
|
||||
#[serde(default)]
|
||||
descriptor: Option<String>,
|
||||
#[serde(default)]
|
||||
descriptors: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
utxos: Option<Vec<UtxoInput>>,
|
||||
}
|
||||
|
||||
async fn scan_post(
|
||||
State(gateway): State<GatewayState>,
|
||||
Json(body): Json<ScanRequestBody>,
|
||||
) -> Result<Json<Report>, 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<ScanTarget, ApiError> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<oneshot::Sender<()>>,
|
||||
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<Arc<dyn stealth_engine::gateway::BlockchainGateway + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ApiServer {
|
||||
async fn spawn(gateway: BitcoinCoreRpc) -> Self {
|
||||
let gateway: Arc<dyn stealth_engine::gateway::BlockchainGateway + Send + Sync> =
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<oneshot::Sender<()>>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<G: BlockchainGateway> std::fmt::Debug for AnalysisEngine<'_, G> {
|
||||
impl<G: BlockchainGateway + ?Sized> 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<G: BlockchainGateway> 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 }
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080'
|
||||
'/api': 'http://localhost:20899'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<N: DescriptorNormalizer + ?Sized>(
|
||||
raw_descriptors: &[String],
|
||||
derivation_range_end: u32,
|
||||
normalizer: &dyn DescriptorNormalizer,
|
||||
normalizer: &N,
|
||||
) -> Result<Vec<ResolvedDescriptor>, AnalysisError> {
|
||||
let mut resolved = Vec::new();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user