From a08aca3af3c853dde4851d96e40b9202f6af1eca Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Sat, 4 Apr 2026 23:04:20 -0300 Subject: [PATCH 1/5] feat(cli): create rust package cli for stealth cli interface --- Cargo.toml | 1 + cli/Cargo.toml | 21 ++++ cli/src/main.rs | 257 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 cli/Cargo.toml create mode 100644 cli/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 8d61bfd..7a68c9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "model", "bitcoincore", "engine", + "cli", ] resolver = "2" diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..2ca12f4 --- /dev/null +++ b/cli/Cargo.toml @@ -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" diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..d9d6e91 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,257 @@ +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 = 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 { + 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, + descriptors_file: Option, + utxos_file: Option, + rpc_url: Option, + rpc_user: Option, + rpc_pass: Option, + rpc_cookie: Option, + format: Option, +} + +impl ScanOpts { + fn build_gateway(&self) -> Result { + 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()), + self.rpc_pass + .clone() + .or_else(|| 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 { + 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 = 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 = 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 { + 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-pass" => { + opts.rpc_pass = Some(take_value(args, &mut i, "--rpc-pass")?); + } + "--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 { + *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 Single output descriptor"); + eprintln!(" --descriptors JSON array of descriptors"); + eprintln!(" --utxos JSON array of {{txid,vout,...}}\n"); + eprintln!("RPC CONNECTION:"); + eprintln!(" --rpc-url bitcoind RPC endpoint"); + eprintln!(" --rpc-user RPC username"); + eprintln!(" --rpc-pass RPC password"); + eprintln!(" --rpc-cookie Path to .cookie file\n"); + eprintln!(" Env vars: STEALTH_RPC_URL, STEALTH_RPC_USER,"); + eprintln!(" STEALTH_RPC_PASS, STEALTH_RPC_COOKIE\n"); + eprintln!("OUTPUT:"); + eprintln!(" --format Output format (default: text)\n"); + eprintln!("EXIT CODES:"); + eprintln!(" 0 scan completed, no findings"); + eprintln!(" 1 scan completed, findings present"); + eprintln!(" 2 error"); +} From fc2d7007ac463fadd3cc477dd8d153ce7c24a070 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Sun, 5 Apr 2026 00:09:10 -0300 Subject: [PATCH 2/5] feat(docs): update README for cli rust crate instructions --- .gitignore | 3 +- README.md | 85 ++++++++++++++++++++++++++++++++++++++++------------ bitcoin.conf | 12 ++++++++ 3 files changed, 80 insertions(+), 20 deletions(-) create mode 100644 bitcoin.conf diff --git a/.gitignore b/.gitignore index 09a9ffd..7bab094 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ dist/ .qwen **/__pycache__/ target/ -Cargo.lock \ No newline at end of file +Cargo.lock +.bitcoin-regtest diff --git a/README.md b/README.md index 1e9896e..249f95f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -162,39 +163,84 @@ Stealth currently runs **12 detectors** in `stealth-engine`. ```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. +Create a local `bitcoin.conf`: ```bash -cd backend/script -./setup.sh +cat > bitcoin.conf <<'EOF' +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 +EOF ``` -### 4. Generate sample transactions +### 3. Start Bitcoin Core + +Regtest example: ```bash -python3 reproduce.py +mkdir -p "$PWD/.bitcoin-regtest" +bitcoind -datadir="$PWD/.bitcoin-regtest" -conf="$PWD/bitcoin.conf" -daemon ``` -### 5. Start backend +Mainnet example: ```bash -cd backend/src/StealthBackend -./mvnw quarkus:dev +bitcoind -daemon ``` -### 6. Start frontend +### 4. Run a usable CLI scan request + +```bash +DATADIR="$PWD/.bitcoin-regtest" +CONF="$PWD/bitcoin.conf" +RPC="bitcoin-cli -datadir=$DATADIR -conf=$CONF -regtest -rpcport=18443" + +mkdir -p "$DATADIR" +if ! $RPC getblockchaininfo >/dev/null 2>&1; then + bitcoind -datadir="$DATADIR" -conf="$CONF" -daemon +fi + +for _ in $(seq 1 100); do + if $RPC getblockchaininfo >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +WALLET="scanwallet_cli" +if ! $RPC -rpcwallet="$WALLET" getwalletinfo >/dev/null 2>&1; then + $RPC loadwallet "$WALLET" >/dev/null 2>&1 || $RPC createwallet "$WALLET" >/dev/null +fi + +DESC="$($RPC -rpcwallet="$WALLET" listdescriptors | \ + python3 -c 'import json,sys; d=json.load(sys.stdin)["descriptors"]; print(next(x["desc"] for x in d if x.get("active") and not x.get("internal") and "/0/*" in x["desc"]))')" +TARGET_ADDR="$($RPC deriveaddresses "$DESC" "[0,0]" | \ + python3 -c 'import json,sys; print(json.load(sys.stdin)[0])')" +$RPC generatetoaddress 101 "$TARGET_ADDR" >/dev/null + +cargo run --bin stealth-cli -- scan \ + --descriptor "$DESC" \ + --rpc-url http://127.0.0.1:18443 \ + --rpc-user localuser \ + --rpc-pass localpass \ + --format text +``` + +### 5. Start frontend ```bash cd frontend @@ -231,7 +277,8 @@ 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 +└── target/ # Cargo build outputs ``` ### Test Coverage diff --git a/bitcoin.conf b/bitcoin.conf new file mode 100644 index 0000000..717c642 --- /dev/null +++ b/bitcoin.conf @@ -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 From 5efb210d6841ccaa786ce81d58f3f9314e8ced58 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Thu, 9 Apr 2026 16:11:22 -0300 Subject: [PATCH 3/5] fixup! refactor(model): convert core crate into model for types and interfaces --- model/src/config.rs | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/model/src/config.rs b/model/src/config.rs index d8a9a5f..6007b09 100644 --- a/model/src/config.rs +++ b/model/src/config.rs @@ -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, /// 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, } } From 1d0479cc83ed5bf1df6b392331f91204f21c975e Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Thu, 9 Apr 2026 16:11:30 -0300 Subject: [PATCH 4/5] fixup! feat(cli): create rust package cli for stealth cli interface --- cli/src/main.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index d9d6e91..ca92886 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -64,7 +64,6 @@ struct ScanOpts { utxos_file: Option, rpc_url: Option, rpc_user: Option, - rpc_pass: Option, rpc_cookie: Option, format: Option, } @@ -81,9 +80,7 @@ impl ScanOpts { self.rpc_user .clone() .or_else(|| env::var("STEALTH_RPC_USER").ok()), - self.rpc_pass - .clone() - .or_else(|| env::var("STEALTH_RPC_PASS").ok()), + env::var("STEALTH_RPC_PASS").ok(), self.rpc_cookie .clone() .or_else(|| env::var("STEALTH_RPC_COOKIE").ok().map(PathBuf::from)), @@ -166,9 +163,6 @@ fn parse_scan_args(args: &[String]) -> Result { "--rpc-user" => { opts.rpc_user = Some(take_value(args, &mut i, "--rpc-user")?); } - "--rpc-pass" => { - opts.rpc_pass = Some(take_value(args, &mut i, "--rpc-pass")?); - } "--rpc-cookie" => { opts.rpc_cookie = Some(PathBuf::from(take_value(args, &mut i, "--rpc-cookie")?)); } @@ -244,8 +238,7 @@ fn print_usage() { eprintln!("RPC CONNECTION:"); eprintln!(" --rpc-url bitcoind RPC endpoint"); eprintln!(" --rpc-user RPC username"); - eprintln!(" --rpc-pass RPC password"); - eprintln!(" --rpc-cookie Path to .cookie file\n"); + eprintln!(" --rpc-cookie 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:"); From 41098fc20968031eec7e9cbe86df53dd47598e48 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Thu, 9 Apr 2026 16:11:38 -0300 Subject: [PATCH 5/5] fixup! feat(docs): update README for cli rust crate instructions --- .gitignore | 1 + README.md | 81 +++++----------------- bitcoin.conf => bitcoin.conf.example | 0 scripts/setup.sh | 100 +++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 63 deletions(-) rename bitcoin.conf => bitcoin.conf.example (100%) create mode 100755 scripts/setup.sh diff --git a/.gitignore b/.gitignore index 7bab094..8e9d282 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ dist/ **/__pycache__/ target/ Cargo.lock +bitcoin.conf .bitcoin-regtest diff --git a/README.md b/README.md index 249f95f..5da96ff 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,11 @@ 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 @@ -168,75 +167,30 @@ cargo build ### 2. Configure Bitcoin Core RPC (regtest) -Create a local `bitcoin.conf`: +Copy the example config: ```bash -cat > bitcoin.conf <<'EOF' -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 -EOF +cp bitcoin.conf.example bitcoin.conf ``` -### 3. Start Bitcoin Core - -Regtest example: +### 3. Start regtest and fund a wallet ```bash -mkdir -p "$PWD/.bitcoin-regtest" -bitcoind -datadir="$PWD/.bitcoin-regtest" -conf="$PWD/bitcoin.conf" -daemon +./scripts/setup.sh ``` -Mainnet example: +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 -bitcoind -daemon -``` - -### 4. Run a usable CLI scan request - -```bash -DATADIR="$PWD/.bitcoin-regtest" -CONF="$PWD/bitcoin.conf" -RPC="bitcoin-cli -datadir=$DATADIR -conf=$CONF -regtest -rpcport=18443" - -mkdir -p "$DATADIR" -if ! $RPC getblockchaininfo >/dev/null 2>&1; then - bitcoind -datadir="$DATADIR" -conf="$CONF" -daemon -fi - -for _ in $(seq 1 100); do - if $RPC getblockchaininfo >/dev/null 2>&1; then - break - fi - sleep 0.2 -done - -WALLET="scanwallet_cli" -if ! $RPC -rpcwallet="$WALLET" getwalletinfo >/dev/null 2>&1; then - $RPC loadwallet "$WALLET" >/dev/null 2>&1 || $RPC createwallet "$WALLET" >/dev/null -fi - -DESC="$($RPC -rpcwallet="$WALLET" listdescriptors | \ - python3 -c 'import json,sys; d=json.load(sys.stdin)["descriptors"]; print(next(x["desc"] for x in d if x.get("active") and not x.get("internal") and "/0/*" in x["desc"]))')" -TARGET_ADDR="$($RPC deriveaddresses "$DESC" "[0,0]" | \ - python3 -c 'import json,sys; print(json.load(sys.stdin)[0])')" -$RPC generatetoaddress 101 "$TARGET_ADDR" >/dev/null - cargo run --bin stealth-cli -- scan \ - --descriptor "$DESC" \ + --descriptor '' \ --rpc-url http://127.0.0.1:18443 \ - --rpc-user localuser \ - --rpc-pass localpass \ + --rpc-cookie .bitcoin-regtest/regtest/.cookie \ --format text ``` @@ -278,6 +232,7 @@ stealth/ │ │ └── bitcoin-data/ # Regtest chain data (gitignored) │ └── src/StealthBackend/ # Quarkus Java REST API (single /api/wallet/scan endpoint) ├── cli/ # stealth-cli +├── scripts/ # Development helper scripts (setup.sh) └── target/ # Cargo build outputs ``` diff --git a/bitcoin.conf b/bitcoin.conf.example similarity index 100% rename from bitcoin.conf rename to bitcoin.conf.example diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..a837de7 --- /dev/null +++ b/scripts/setup.sh @@ -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"