Merge pull request #17 from satsfy/add-api

refactor: implement stealth api as rust crate
This commit is contained in:
LORDBABUINO
2026-05-26 14:13:18 -03:00
committed by GitHub
18 changed files with 949 additions and 16 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
**/.yarn/install-state.gz
.env
.env.local
*.local
+1
View File
@@ -3,6 +3,7 @@ members = [
"model",
"bitcoincore",
"engine",
"api",
"cli",
]
resolver = "2"
+26 -8
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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
```
+73
View File
@@ -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,
}
+24
View File
@@ -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
View File
@@ -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))
}
+1
View File
@@ -0,0 +1 @@
pub mod wallet;
+195
View File
@@ -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()
}
}
+160
View File
@@ -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();
}
}
}
+138
View File
@@ -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;
}
}
+1 -1
View File
@@ -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();
+3 -3
View File
@@ -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.
+5 -1
View File
@@ -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()
}
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080'
'/api': 'http://localhost:20899'
}
}
})
+2 -2
View File
@@ -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();