Skip to content

refactor: implement stealth api as rust crate #17

Open
satsfy wants to merge 2 commits intostealth-bitcoin:mainfrom
satsfy:add-api
Open

refactor: implement stealth api as rust crate #17
satsfy wants to merge 2 commits intostealth-bitcoin:mainfrom
satsfy:add-api

Conversation

@satsfy
Copy link
Copy Markdown
Collaborator

@satsfy satsfy commented Mar 26, 2026

Depends on #16 (it will be in draft mode until its merged)

Solves #10. This PR is a combination of rust refactors from @brenorb in #15 and me.

Implements an HTTP API for stealth as rust create stealth-api with POST /api/wallet/scan, request validation, structured error responses, RPC autodetection, and regtest end-to-end tests.

Replaces instructions on README on how to use stealth api.

Reviewer Notes

Open the branch locally, run cargo build to verify it compiles and run cargo test. Inspect the e2e test in api/tests/.

Run the api with cargo run --bin stealth-api and test that call:

curl 'http://localhost:20899/api/wallet/scan' \
  -H 'content-type: application/json' \
  -d '{"descriptor":"wpkh(xpub.../0/*)"}' | jq

@satsfy satsfy changed the title Add api refactor: implement stealth api as rust crate Mar 26, 2026
@satsfy satsfy force-pushed the add-api branch 2 times, most recently from 3be5813 to c230e67 Compare April 5, 2026 23:28
@satsfy satsfy marked this pull request as ready for review April 5, 2026 23:31
@satsfy
Copy link
Copy Markdown
Collaborator Author

satsfy commented Apr 9, 2026

@LORDBABUINO this and PR #19 are also open for review.

Comment thread api/src/main.rs
Comment on lines +142 to +149
for candidate in cookie_candidates(&bitcoin_dir, port) {
if candidate.exists() {
return Some(candidate);
}
}
cookie_candidates(&bitcoin_dir, port)
.into_iter()
.find(|candidate| candidate.exists())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should select only one of these 2 loops because they do the same thing and after the first one returns, the second one is dead code

Suggested change
for candidate in cookie_candidates(&bitcoin_dir, port) {
if candidate.exists() {
return Some(candidate);
}
}
cookie_candidates(&bitcoin_dir, port)
.into_iter()
.find(|candidate| candidate.exists())
cookie_candidates(&bitcoin_dir, port)
.into_iter()
.find(|candidate| candidate.exists())

Comment thread api/src/lib.rs
Comment on lines +19 to +23
pub fn app_with_gateway(gateway: GatewayState) -> Router {
Router::new()
.nest("/api/wallet", routes::wallet::router())
.with_state(gateway)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without a CORS layer browser frontends will deny connections to it

Comment thread api/src/routes/wallet.rs
Comment on lines +43 to +77
fn into_scan_target(self) -> Result<ScanTarget, ApiError> {
let mut selected_sources = 0usize;
if self.descriptor.is_some() {
selected_sources += 1;
}
if self.descriptors.is_some() {
selected_sources += 1;
}
if self.utxos.is_some() {
selected_sources += 1;
}

if selected_sources == 0 {
return Err(ApiError::bad_request(
"one input source is required: descriptor, descriptors, or utxos",
));
}
if selected_sources > 1 {
return Err(ApiError::bad_request(
"descriptor, descriptors, and utxos are mutually exclusive",
));
}

if let Some(descriptor) = self.descriptor {
return Ok(ScanTarget::Descriptor(descriptor));
}
if let Some(descriptors) = self.descriptors {
return Ok(ScanTarget::Descriptors(descriptors));
}
if let Some(utxos) = self.utxos {
return Ok(ScanTarget::Utxos(utxos));
}

Err(ApiError::bad_request("invalid scan request body"))
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fn into_scan_target(self) -> Result<ScanTarget, ApiError> {
let mut selected_sources = 0usize;
if self.descriptor.is_some() {
selected_sources += 1;
}
if self.descriptors.is_some() {
selected_sources += 1;
}
if self.utxos.is_some() {
selected_sources += 1;
}
if selected_sources == 0 {
return Err(ApiError::bad_request(
"one input source is required: descriptor, descriptors, or utxos",
));
}
if selected_sources > 1 {
return Err(ApiError::bad_request(
"descriptor, descriptors, and utxos are mutually exclusive",
));
}
if let Some(descriptor) = self.descriptor {
return Ok(ScanTarget::Descriptor(descriptor));
}
if let Some(descriptors) = self.descriptors {
return Ok(ScanTarget::Descriptors(descriptors));
}
if let Some(utxos) = self.utxos {
return Ok(ScanTarget::Utxos(utxos));
}
Err(ApiError::bad_request("invalid scan request body"))
}
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) => Ok(ScanTarget::Descriptors(ds)),
(None, None, Some(utxos)) => Ok(ScanTarget::Utxos(utxos)),
(None, None, None) => Err(ApiError::bad_request(
"one input source is required: descriptor, descriptors, or utxos",
)),
_ => Err(ApiError::bad_request(
"descriptor, descriptors, and utxos are mutually exclusive",
)),
}
}

Comment on lines +35 to +42
let cookie =
std::fs::read_to_string(&node.params.cookie_file).expect("failed to read cookie file");
let gateway = tokio::task::spawn_blocking(move || {
let mut parts = cookie.trim().splitn(2, ':');
let user = parts.next().unwrap().to_string();
let pass = parts.next().unwrap().to_string();
BitcoinCoreRpc::from_url(&rpc_url, Some(user), Some(pass)).expect("failed to build gateway")
})
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you should just use the already implemented function that reads the cookie read_cookie_file from stealth_bitcoincore

Suggested change
let cookie =
std::fs::read_to_string(&node.params.cookie_file).expect("failed to read cookie file");
let gateway = tokio::task::spawn_blocking(move || {
let mut parts = cookie.trim().splitn(2, ':');
let user = parts.next().unwrap().to_string();
let pass = parts.next().unwrap().to_string();
BitcoinCoreRpc::from_url(&rpc_url, Some(user), Some(pass)).expect("failed to build gateway")
})
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")
})

Comment thread api/src/preflight.rs
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stealth runs on top of the user's own Bitcoin node — that's a core design principle. The node is the authority on what constitutes a valid descriptor, and different node versions/forks may accept different descriptor formats (e.g. newer script types, different checksum rules, Miniscript extensions).

By reimplementing descriptor validation here, we're overriding the node's own rules. A descriptor that the user's node would accept might get rejected by our preflight, or vice versa. Either case is a bug we'd have to maintain indefinitely as descriptor standards evolve.

The descriptor already goes through getdescriptorinfo (normalization) and importdescriptors on the node — both return clear errors for malformed input. We should trust that path and let the node be the single source of truth for validation.

I'd suggest simplifying preflight.rs to only check what the node can't tell us — things like "did the user send an empty body" or "did they mix mutually exclusive fields." The structural descriptor checks (parentheses balancing, supported prefixes, checksum format) should be removed and left to the node.

Comment thread README.md
Comment on lines +173 to +187
```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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should tell the user to just copy bitcoin.conf.example to bitcoin.conf

Comment thread README.md
Comment on lines +218 to 254
```bash
DATADIR="$PWD/.bitcoin-regtest"
CONF="$PWD/bitcoin.conf"
RPC="bitcoin-cli -datadir=$DATADIR -conf=$CONF -regtest -rpcport=18443"
API_PORT=20899

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

STEALTH_RPC_URL=http://127.0.0.1:18443 \
STEALTH_RPC_USER=localuser \
STEALTH_RPC_PASS=localpass \
STEALTH_API_BIND=127.0.0.1:$API_PORT \
cargo run --bin stealth-api >/tmp/stealth-api.log 2>&1 &
API_PID=$!
trap 'kill $API_PID >/dev/null 2>&1 || true' EXIT

WALLET="scanwallet_$(date +%s)"
$RPC createwallet "$WALLET" >/dev/null
TARGET_ADDR="$($RPC -rpcwallet="$WALLET" getnewaddress)"
$RPC generatetoaddress 101 "$TARGET_ADDR" >/dev/null

DESC="$($RPC -rpcwallet="$WALLET" getaddressinfo "$TARGET_ADDR" | jq -r '.desc')"

curl -s "http://127.0.0.1:$API_PORT/api/wallet/scan" -H 'content-type: application/json' -d "{\"descriptor\":\"$DESC\"}" | jq

$RPC stop
```
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this huge bash script should be covered by ./scripts/setup.sh like we talked in #18 .

Suggested change
```bash
DATADIR="$PWD/.bitcoin-regtest"
CONF="$PWD/bitcoin.conf"
RPC="bitcoin-cli -datadir=$DATADIR -conf=$CONF -regtest -rpcport=18443"
API_PORT=20899
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
STEALTH_RPC_URL=http://127.0.0.1:18443 \
STEALTH_RPC_USER=localuser \
STEALTH_RPC_PASS=localpass \
STEALTH_API_BIND=127.0.0.1:$API_PORT \
cargo run --bin stealth-api >/tmp/stealth-api.log 2>&1 &
API_PID=$!
trap 'kill $API_PID >/dev/null 2>&1 || true' EXIT
WALLET="scanwallet_$(date +%s)"
$RPC createwallet "$WALLET" >/dev/null
TARGET_ADDR="$($RPC -rpcwallet="$WALLET" getnewaddress)"
$RPC generatetoaddress 101 "$TARGET_ADDR" >/dev/null
DESC="$($RPC -rpcwallet="$WALLET" getaddressinfo "$TARGET_ADDR" | jq -r '.desc')"
curl -s "http://127.0.0.1:$API_PORT/api/wallet/scan" -H 'content-type: application/json' -d "{\"descriptor\":\"$DESC\"}" | jq
$RPC stop
```
#### Set up regtest (starts node, creates wallet, mines blocks)
./scripts/setup.sh
#### Start the API
cargo run --bin stealth-api
#### Scan (in another terminal)
curl -s 'http://localhost:20899/api/wallet/scan' \
-H 'content-type: application/json' \
-d '{"descriptor":"<descriptor from setup.sh output>"}' | jq

Comment thread api/src/main.rs
Comment on lines +172 to +209
fn read_bitcoin_conf_credentials() -> Option<(String, String)> {
let conf_path = PathBuf::from("bitcoin.conf");
let conf = std::fs::read_to_string(conf_path).ok()?;

let mut user: Option<String> = None;
let mut pass: Option<String> = None;

for raw_line in conf.lines() {
let line = raw_line.trim();
if line.is_empty()
|| line.starts_with('#')
|| line.starts_with(';')
|| line.starts_with('[')
{
continue;
}

let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let key = raw_key.trim();
let value = raw_value.trim();
if value.is_empty() {
continue;
}

match key {
"rpcuser" => user = Some(value.to_owned()),
"rpcpassword" => pass = Some(value.to_owned()),
_ => {}
}
}

match (user, pass) {
(Some(user), Some(pass)) => Some((user, pass)),
_ => None,
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is reimplementing an INI parser. You should just import ini::Ini and use it.

Suggested change
fn read_bitcoin_conf_credentials() -> Option<(String, String)> {
let conf_path = PathBuf::from("bitcoin.conf");
let conf = std::fs::read_to_string(conf_path).ok()?;
let mut user: Option<String> = None;
let mut pass: Option<String> = None;
for raw_line in conf.lines() {
let line = raw_line.trim();
if line.is_empty()
|| line.starts_with('#')
|| line.starts_with(';')
|| line.starts_with('[')
{
continue;
}
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let key = raw_key.trim();
let value = raw_value.trim();
if value.is_empty() {
continue;
}
match key {
"rpcuser" => user = Some(value.to_owned()),
"rpcpassword" => pass = Some(value.to_owned()),
_ => {}
}
}
match (user, pass) {
(Some(user), Some(pass)) => Some((user, pass)),
_ => None,
}
}
fn read_bitcoin_conf_credentials() -> Option<(String, String)> {
let conf = Ini::load_from_file("bitcoin.conf").ok()?;
let section = conf.general_section();
let user = section.get("rpcuser")?;
let pass = section.get("rpcpassword")?;
Some((user.to_owned(), pass.to_owned()))
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants