Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
**/.yarn/install-state.gz
.env
.env.local
*.local
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ members = [
"model",
"bitcoincore",
"engine",
"api",
"cli",
]
resolver = "2"
Expand Down
34 changes: 26 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand All @@ -194,7 +208,7 @@ cargo run --bin stealth-cli -- scan \
--format text
```
Comment thread
LORDBABUINO marked this conversation as resolved.

### 5. Start frontend
### 7. Start frontend

```bash
cd frontend
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions api/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 changes: 107 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 73 additions & 0 deletions api/src/error.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
LORDBABUINO marked this conversation as resolved.
Loading
Loading