refactor: implement stealth api as rust crate #17
refactor: implement stealth api as rust crate #17satsfy wants to merge 2 commits intostealth-bitcoin:mainfrom
Conversation
3be5813 to
c230e67
Compare
|
@LORDBABUINO this and PR #19 are also open for review. |
| 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()) |
There was a problem hiding this comment.
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
| 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()) |
| pub fn app_with_gateway(gateway: GatewayState) -> Router { | ||
| Router::new() | ||
| .nest("/api/wallet", routes::wallet::router()) | ||
| .with_state(gateway) | ||
| } |
There was a problem hiding this comment.
Without a CORS layer browser frontends will deny connections to it
| 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")) | ||
| } |
There was a problem hiding this comment.
| 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", | |
| )), | |
| } | |
| } |
| 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") | ||
| }) |
There was a problem hiding this comment.
Here you should just use the already implemented function that reads the cookie read_cookie_file from stealth_bitcoincore
| 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") | |
| }) |
There was a problem hiding this comment.
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.
| ```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 |
There was a problem hiding this comment.
we should tell the user to just copy bitcoin.conf.example to bitcoin.conf
| ```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 | ||
| ``` |
There was a problem hiding this comment.
this huge bash script should be covered by ./scripts/setup.sh like we talked in #18 .
| ```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 |
| 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, | ||
| } | ||
| } |
There was a problem hiding this comment.
This function is reimplementing an INI parser. You should just import ini::Ini and use it.
| 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())) | |
| } |
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-apiwithPOST /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 buildto verify it compiles and runcargo test. Inspect the e2e test inapi/tests/.Run the api with
cargo run --bin stealth-apiand test that call: