Merge pull request #18 from satsfy/add-cli

refactor: implement stealth cli as rust crate
This commit is contained in:
LORDBABUINO
2026-04-10 18:49:52 -03:00
committed by GitHub
8 changed files with 414 additions and 60 deletions

4
.gitignore vendored
View File

@@ -12,4 +12,6 @@ dist/
.qwen
**/__pycache__/
target/
Cargo.lock
Cargo.lock
bitcoin.conf
.bitcoin-regtest

View File

@@ -3,6 +3,7 @@ members = [
"model",
"bitcoincore",
"engine",
"cli",
]
resolver = "2"

View File

@@ -31,6 +31,7 @@ Stealth ships a Rust workspace with:
- `stealth-engine` (analysis engine)
- `stealth-model` (domain model types and interfaces)
- `stealth-cli`
- `stealth-bitcoincore` (Bitcoin Core RPC gateway adapter)
## Project Direction
@@ -150,51 +151,50 @@ Stealth currently runs **12 detectors** in `stealth-engine`.
### Prerequisites
| Dependency | Version | Purpose |
| -------------- | ------- | --------------- |
| Bitcoin Core | ≥ 26 | Local node |
| Python | ≥ 3.10 | Analysis engine |
| Java | 21 | Backend |
| Node.js + yarn | ≥ 18 | Frontend |
| Dependency | Version | Purpose |
| -------------- | ------- | ----------------- |
| Bitcoin Core | ≥ 26 | Local node |
| Rust toolchain | ≥ 1.56 | CLI + engine |
| Node.js + yarn | ≥ 18 | Frontend |
### 1. Clone the repository
```bash
git clone https://github.com/stealth-bitcoin/stealth.git
cd stealth
cargo build
```
### 2. Configure blockchain connection
### 2. Configure Bitcoin Core RPC (regtest)
Edit:
```
backend/script/config.ini
```
### 3. Development setup (regtest)
A regtest environment is provided for development and reproducible testing of heuristics.
Copy the example config:
```bash
cd backend/script
./setup.sh
cp bitcoin.conf.example bitcoin.conf
```
### 4. Generate sample transactions
### 3. Start regtest and fund a wallet
```bash
python3 reproduce.py
./scripts/setup.sh
```
### 5. Start backend
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
```bash
cd backend/src/StealthBackend
./mvnw quarkus:dev
cargo run --bin stealth-cli -- scan \
--descriptor '<descriptor from setup.sh output>' \
--rpc-url http://127.0.0.1:18443 \
--rpc-cookie .bitcoin-regtest/regtest/.cookie \
--format text
```
### 6. Start frontend
### 5. Start frontend
```bash
cd frontend
@@ -231,7 +231,9 @@ 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
── cli/ # stealth-cli
├── scripts/ # Development helper scripts (setup.sh)
└── target/ # Cargo build outputs
```
### Test Coverage

12
bitcoin.conf.example Normal file
View File

@@ -0,0 +1,12 @@
regtest=1
server=1
daemon=1
txindex=1
listen=0
[regtest]
rpcbind=127.0.0.1
rpcallowip=127.0.0.1
rpcuser=localuser
rpcpassword=localpass
rpcport=18443
fallbackfee=0.0002

21
cli/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "stealth-cli"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Detects UTXO privacy vulnerabilities in wallets"
categories = ["cryptography::cryptocurrencies"]
keywords = ["bitcoin", "privacy", "cli"]
exclude = ["tests"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
stealth-bitcoincore = { path = "../bitcoincore" }
stealth-engine = { workspace = true }
[lints.rust]
missing_debug_implementations = "deny"

250
cli/src/main.rs Normal file
View File

@@ -0,0 +1,250 @@
use std::path::PathBuf;
use std::process::ExitCode;
use std::{env, fs};
use stealth_bitcoincore::{read_cookie_file, BitcoinCoreRpc};
use stealth_engine::engine::{AnalysisEngine, EngineSettings, ScanTarget, UtxoInput};
fn main() -> ExitCode {
let args: Vec<String> = env::args().skip(1).collect();
if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
print_usage();
return ExitCode::SUCCESS;
}
if args[0] != "scan" {
eprintln!(
"error: unknown command '{}' (try 'stealth-cli --help')",
args[0]
);
return ExitCode::from(2);
}
match run_scan(&args[1..]) {
Ok(clean) => {
if clean {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
Err(message) => {
eprintln!("error: {message}");
ExitCode::from(2)
}
}
}
fn run_scan(args: &[String]) -> Result<bool, String> {
let opts = parse_scan_args(args)?;
let gateway = opts.build_gateway()?;
let target = opts.scan_target()?;
let engine = AnalysisEngine::new(&gateway, EngineSettings::default());
let report = engine.analyze(target).map_err(|e| e.to_string())?;
match opts.format.as_deref() {
Some("text") | None => print_text_report(&report),
Some("json") => {
let json = serde_json::to_string_pretty(&report)
.map_err(|e| format!("serialization failed: {e}"))?;
println!("{json}");
}
Some(other) => return Err(format!("unsupported format '{other}' (use json or text)")),
}
Ok(report.summary.clean)
}
#[derive(Debug, Default)]
struct ScanOpts {
descriptor: Option<String>,
descriptors_file: Option<PathBuf>,
utxos_file: Option<PathBuf>,
rpc_url: Option<String>,
rpc_user: Option<String>,
rpc_cookie: Option<PathBuf>,
format: Option<String>,
}
impl ScanOpts {
fn build_gateway(&self) -> Result<BitcoinCoreRpc, String> {
let url = self
.rpc_url
.clone()
.or_else(|| env::var("STEALTH_RPC_URL").ok())
.ok_or("--rpc-url or STEALTH_RPC_URL is required")?;
let (user, pass) = match (
self.rpc_user
.clone()
.or_else(|| env::var("STEALTH_RPC_USER").ok()),
env::var("STEALTH_RPC_PASS").ok(),
self.rpc_cookie
.clone()
.or_else(|| env::var("STEALTH_RPC_COOKIE").ok().map(PathBuf::from)),
) {
(Some(user), Some(pass), _) => (Some(user), Some(pass)),
(_, _, Some(cookie_path)) => {
let (u, p) = read_cookie_file(&cookie_path).map_err(|e| e.to_string())?;
(Some(u), Some(p))
}
_ => (None, None),
};
BitcoinCoreRpc::from_url(&url, user, pass).map_err(|e| e.to_string())
}
fn scan_target(&self) -> Result<ScanTarget, String> {
let mut sources = 0usize;
if self.descriptor.is_some() {
sources += 1;
}
if self.descriptors_file.is_some() {
sources += 1;
}
if self.utxos_file.is_some() {
sources += 1;
}
if sources == 0 {
return Err(
"one input is required: --descriptor, --descriptors, or --utxos".to_owned(),
);
}
if sources > 1 {
return Err(
"--descriptor, --descriptors, and --utxos are mutually exclusive".to_owned(),
);
}
if let Some(d) = &self.descriptor {
return Ok(ScanTarget::Descriptor(d.clone()));
}
if let Some(path) = &self.descriptors_file {
let content = fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let descriptors: Vec<String> = serde_json::from_str(&content)
.map_err(|e| format!("invalid JSON in {}: {e}", path.display()))?;
return Ok(ScanTarget::Descriptors(descriptors));
}
if let Some(path) = &self.utxos_file {
let content = fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let utxos: Vec<UtxoInput> = serde_json::from_str(&content)
.map_err(|e| format!("invalid JSON in {}: {e}", path.display()))?;
return Ok(ScanTarget::Utxos(utxos));
}
Err("no scan target specified".to_owned())
}
}
fn parse_scan_args(args: &[String]) -> Result<ScanOpts, String> {
let mut opts = ScanOpts::default();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--descriptor" => {
opts.descriptor = Some(take_value(args, &mut i, "--descriptor")?);
}
"--descriptors" => {
opts.descriptors_file =
Some(PathBuf::from(take_value(args, &mut i, "--descriptors")?));
}
"--utxos" => {
opts.utxos_file = Some(PathBuf::from(take_value(args, &mut i, "--utxos")?));
}
"--rpc-url" => {
opts.rpc_url = Some(take_value(args, &mut i, "--rpc-url")?);
}
"--rpc-user" => {
opts.rpc_user = Some(take_value(args, &mut i, "--rpc-user")?);
}
"--rpc-cookie" => {
opts.rpc_cookie = Some(PathBuf::from(take_value(args, &mut i, "--rpc-cookie")?));
}
"--format" => {
opts.format = Some(take_value(args, &mut i, "--format")?);
}
other => return Err(format!("unknown flag '{other}'")),
}
i += 1;
}
Ok(opts)
}
fn take_value(args: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
*i += 1;
let value = args
.get(*i)
.ok_or_else(|| format!("{flag} requires a value"))?;
if value.starts_with('-') {
return Err(format!("{flag} requires a value"));
}
Ok(value.clone())
}
fn print_text_report(report: &stealth_engine::Report) {
println!(
"Scanned {} transactions, {} addresses, {} current UTXOs\n",
report.stats.transactions_analyzed, report.stats.addresses_seen, report.stats.utxos_current,
);
if report.summary.clean {
println!("No privacy issues found.");
return;
}
if !report.findings.is_empty() {
println!("Findings ({}):", report.findings.len());
for f in &report.findings {
println!(
" [{severity}] {vtype}: {desc}",
severity = f.severity,
vtype = f.vulnerability_type,
desc = f.description,
);
}
println!();
}
if !report.warnings.is_empty() {
println!("Warnings ({}):", report.warnings.len());
for w in &report.warnings {
println!(
" [{severity}] {vtype}: {desc}",
severity = w.severity,
vtype = w.vulnerability_type,
desc = w.description,
);
}
}
}
fn print_usage() {
eprintln!("stealth-cli Bitcoin UTXO privacy vulnerability scanner\n");
eprintln!("USAGE:");
eprintln!(" stealth-cli scan [OPTIONS]\n");
eprintln!("SCAN INPUT (one required, mutually exclusive):");
eprintln!(" --descriptor <DESC> Single output descriptor");
eprintln!(" --descriptors <FILE> JSON array of descriptors");
eprintln!(" --utxos <FILE> JSON array of {{txid,vout,...}}\n");
eprintln!("RPC CONNECTION:");
eprintln!(" --rpc-url <URL> bitcoind RPC endpoint");
eprintln!(" --rpc-user <USER> RPC username");
eprintln!(" --rpc-cookie <PATH> Path to .cookie file (recommended)\n");
eprintln!(" Env vars: STEALTH_RPC_URL, STEALTH_RPC_USER,");
eprintln!(" STEALTH_RPC_PASS, STEALTH_RPC_COOKIE\n");
eprintln!("OUTPUT:");
eprintln!(" --format <text|json> Output format (default: text)\n");
eprintln!("EXIT CODES:");
eprintln!(" 0 scan completed, no findings");
eprintln!(" 1 scan completed, findings present");
eprintln!(" 2 error");
}

View File

@@ -1,24 +1,5 @@
use std::collections::HashSet;
use bitcoin::Amount;
/// Identifies a specific detector for enable/disable configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DetectorId {
AddressReuse,
Cioh,
Dust,
DustSpending,
ChangeDetection,
Consolidation,
ScriptTypeMixing,
ClusterMerge,
UtxoAgeSpread,
ExchangeOrigin,
TaintedUtxoMerge,
BehavioralFingerprint,
}
/// Numeric thresholds used by the detectors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectorThresholds {
@@ -58,7 +39,6 @@ impl Default for DetectorThresholds {
pub struct AnalysisConfig {
pub derivation_range_end: u32,
pub thresholds: DetectorThresholds,
pub enabled_detectors: HashSet<DetectorId>,
/// Maximum ancestor-fetch depth when resolving UTXO history.
/// `0` means only UTXO's own tx; `2` (the default)
pub max_ancestor_depth: u32,
@@ -69,20 +49,6 @@ impl Default for AnalysisConfig {
Self {
derivation_range_end: 999,
thresholds: DetectorThresholds::default(),
enabled_detectors: HashSet::from([
DetectorId::AddressReuse,
DetectorId::Cioh,
DetectorId::Dust,
DetectorId::DustSpending,
DetectorId::ChangeDetection,
DetectorId::Consolidation,
DetectorId::ScriptTypeMixing,
DetectorId::ClusterMerge,
DetectorId::UtxoAgeSpread,
DetectorId::ExchangeOrigin,
DetectorId::TaintedUtxoMerge,
DetectorId::BehavioralFingerprint,
]),
max_ancestor_depth: 2,
}
}

100
scripts/setup.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# =============================================================================
# setup.sh — Bootstrap Bitcoin Core regtest for stealth-cli development
# =============================================================================
# Creates a local regtest environment with a funded wallet, then prints the
# descriptor and a ready-to-use stealth-cli command.
#
# Prerequisites: bitcoind, bitcoin-cli, cargo (Rust toolchain).
#
# Usage:
# ./scripts/setup.sh # keep existing chain state
# ./scripts/setup.sh --fresh # wipe regtest, start from genesis
# =============================================================================
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONF="$REPO_DIR/bitcoin.conf"
DATADIR="$REPO_DIR/.bitcoin-regtest"
WALLET="scanwallet_cli"
INITIAL_BLOCKS=101
# ─── Parse args ───────────────────────────────────────────────────────────────
FRESH=0
for arg in "$@"; do
[[ "$arg" == "--fresh" ]] && FRESH=1
done
# ─── Ensure bitcoin.conf exists ──────────────────────────────────────────────
if [[ ! -f "$CONF" ]]; then
if [[ -f "$REPO_DIR/bitcoin.conf.example" ]]; then
cp "$REPO_DIR/bitcoin.conf.example" "$CONF"
echo "Copied bitcoin.conf.example → bitcoin.conf"
else
echo "error: bitcoin.conf not found (copy bitcoin.conf.example first)" >&2
exit 1
fi
fi
# ─── Helpers ──────────────────────────────────────────────────────────────────
bcli() { bitcoin-cli -datadir="$DATADIR" -conf="$CONF" -regtest -rpcport=18443 "$@"; }
# ─── Optionally wipe regtest chain ───────────────────────────────────────────
if [[ $FRESH -eq 1 ]]; then
bcli stop 2>/dev/null || true
sleep 2
rm -rf "$DATADIR"
echo "Wiped regtest data"
fi
# ─── Start bitcoind if not running ───────────────────────────────────────────
mkdir -p "$DATADIR"
if ! bcli getblockchaininfo >/dev/null 2>&1; then
bitcoind -datadir="$DATADIR" -conf="$CONF" -daemon
echo -n "Waiting for bitcoind"
for _ in $(seq 1 60); do
if bcli getblockchaininfo >/dev/null 2>&1; then
echo " ready"
break
fi
echo -n "."
sleep 0.5
done
fi
# ─── Create / load wallet ────────────────────────────────────────────────────
if ! bcli -rpcwallet="$WALLET" getwalletinfo >/dev/null 2>&1; then
bcli loadwallet "$WALLET" >/dev/null 2>&1 || bcli createwallet "$WALLET" >/dev/null
fi
# ─── Mine initial blocks ─────────────────────────────────────────────────────
BLOCKS=$(bcli getblockcount)
if [[ $BLOCKS -lt $INITIAL_BLOCKS ]]; then
NEED=$(( INITIAL_BLOCKS - BLOCKS ))
ADDR=$(bcli -rpcwallet="$WALLET" getnewaddress "" bech32)
bcli generatetoaddress "$NEED" "$ADDR" >/dev/null
echo "Mined $NEED blocks (now at $(bcli getblockcount))"
fi
# ─── Print descriptor for stealth-cli ─────────────────────────────────────────
DESC=$(bcli -rpcwallet="$WALLET" listdescriptors \
| grep -o '"desc":"[^"]*"' \
| grep '/0/\*' \
| grep -v 'internal' \
| head -1 \
| sed 's/"desc":"//;s/"$//')
COOKIE="$DATADIR/regtest/.cookie"
echo ""
echo "Regtest ready."
echo ""
echo "Descriptor:"
echo " $DESC"
echo ""
echo "Run:"
echo " cargo run --bin stealth-cli -- scan \\"
echo " --descriptor '$DESC' \\"
echo " --rpc-url http://127.0.0.1:18443 \\"
echo " --rpc-cookie '$COOKIE' \\"
echo " --format text"