mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-06-15 08:33:35 -07:00
refactor(bitcoincore): convert bitcoincore into gateway adapter
Removed from crates/stealth-bitcoincore and moved to bitcoincore as a standalone package. This change is part of the refactor to create separate packages for each component of the stealth project, allowing for better modularity and separation of concerns.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"model",
|
||||
"bitcoincore",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "stealth-bitcoincore"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Bitcoin Core RPC gateway for stealth-engine"
|
||||
|
||||
[dependencies]
|
||||
bitcoin = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
ini = { package = "rust-ini", version = "0.21" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
stealth-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
urlencoding = "2.1"
|
||||
@@ -0,0 +1,138 @@
|
||||
# stealth-bitcoincore
|
||||
|
||||
`stealth-bitcoincore` is the Bitcoin Core JSON-RPC gateway implementation for
|
||||
[`stealth-engine`](../core/README.md).
|
||||
|
||||
It implements `stealth_engine::gateway::BlockchainGateway` and is used by both:
|
||||
|
||||
- `stealth-cli` (terminal scans)
|
||||
- `stealth-api` (HTTP scans)
|
||||
|
||||
## What it does
|
||||
|
||||
This crate wraps the Bitcoin Core RPC surface needed by the analysis engine:
|
||||
|
||||
- descriptor normalization and derivation
|
||||
- descriptor import into temporary watch-only wallets
|
||||
- wallet history + UTXO retrieval
|
||||
- raw transaction expansion (including ancestry walk)
|
||||
|
||||
The output is converted into `stealth-engine` gateway types (`WalletHistory`,
|
||||
`DecodedTransaction`, `Utxo`, etc), so the engine can run detectors without
|
||||
knowing anything about RPC transport details.
|
||||
|
||||
## Authentication and configuration
|
||||
|
||||
The gateway supports:
|
||||
|
||||
- RPC user/password
|
||||
- Bitcoin Core cookie auth
|
||||
|
||||
`BitcoinCoreRpc::from_url(...)` does not auto-discover cookie files. If you
|
||||
want internal cookie lookup, construct with `BitcoinCoreConfig` and set
|
||||
`datadir`.
|
||||
|
||||
### 1) Build from URL (used by API/CLI)
|
||||
|
||||
```rust
|
||||
use stealth_bitcoincore::BitcoinCoreRpc;
|
||||
|
||||
let gateway = BitcoinCoreRpc::from_url(
|
||||
"http://127.0.0.1:18443",
|
||||
Some("rpcuser".to_owned()),
|
||||
Some("rpcpassword".to_owned()),
|
||||
)?;
|
||||
# let _ = gateway;
|
||||
# Ok::<(), stealth_model::error::AnalysisError>(())
|
||||
```
|
||||
|
||||
Pass explicit credentials (or parse a cookie file yourself and pass those
|
||||
values here).
|
||||
|
||||
### 2) Build from INI file
|
||||
|
||||
```ini
|
||||
[bitcoin]
|
||||
network=regtest
|
||||
datadir=/home/user/.bitcoin
|
||||
rpchost=127.0.0.1
|
||||
rpcport=18443
|
||||
rpcuser=rpcuser
|
||||
rpcpassword=rpcpassword
|
||||
```
|
||||
|
||||
```rust
|
||||
use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc};
|
||||
|
||||
let config = BitcoinCoreConfig::from_ini_file("stealth.ini")?;
|
||||
let gateway = BitcoinCoreRpc::new(config)?;
|
||||
# let _ = gateway;
|
||||
# Ok::<(), stealth_model::error::AnalysisError>(())
|
||||
```
|
||||
|
||||
Config defaults:
|
||||
|
||||
- `network`: `regtest`
|
||||
- `rpchost`: `127.0.0.1`
|
||||
- `rpcport`: inferred from network (`8332` mainnet, `18332` testnet, `38332` signet, `18443` regtest)
|
||||
- `datadir`: optional (required for cookie fallback)
|
||||
|
||||
Cookie lookup:
|
||||
|
||||
- mainnet: `<datadir>/.cookie`
|
||||
- other networks: `<datadir>/<network>/.cookie`, then `<datadir>/.cookie`
|
||||
|
||||
## Using with the analysis engine
|
||||
|
||||
```rust,ignore
|
||||
use stealth_bitcoincore::BitcoinCoreRpc;
|
||||
use stealth_engine::{AnalysisEngine, EngineSettings, ScanTarget};
|
||||
|
||||
let gateway = BitcoinCoreRpc::from_url(
|
||||
"http://127.0.0.1:18443",
|
||||
Some("rpcuser".to_owned()),
|
||||
Some("rpcpassword".to_owned()),
|
||||
)?;
|
||||
|
||||
let engine = AnalysisEngine::new(&gateway, EngineSettings::default());
|
||||
let report = engine.analyze(ScanTarget::Descriptor(
|
||||
"wpkh([f23f9fd2/84h/1h/0h]tpub.../0/*)".to_owned(),
|
||||
))?;
|
||||
|
||||
println!("clean: {}", report.summary.clean);
|
||||
# Ok::<(), stealth_model::error::AnalysisError>(())
|
||||
```
|
||||
|
||||
## RPC methods used
|
||||
|
||||
| Gateway behavior | Bitcoin Core RPC |
|
||||
| --- | --- |
|
||||
| Descriptor normalization | `getdescriptorinfo` |
|
||||
| Address derivation | `deriveaddresses` |
|
||||
| Temporary wallet creation | `createwallet` |
|
||||
| Descriptor import | `importdescriptors` |
|
||||
| Wallet tx list | `listtransactions` |
|
||||
| UTXO list | `listunspent` |
|
||||
| Raw tx decode/history expansion | `getrawtransaction` (verbose) |
|
||||
| Wallet descriptor listing | `listdescriptors` |
|
||||
| Wallet cleanup | `unloadwallet` |
|
||||
|
||||
## Notes
|
||||
|
||||
- Descriptor scans create a temporary wallet named `_stealth_scan_<timestamp_ms>`
|
||||
and unload it after collection.
|
||||
- Most gateway failures are surfaced as
|
||||
`AnalysisError::EnvironmentUnavailable(...)` with the underlying RPC/context
|
||||
message.
|
||||
- For robust transaction lookups beyond wallet-only data, running `bitcoind`
|
||||
with `txindex=1` is recommended.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo test -p stealth-bitcoincore
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](../LICENSE)
|
||||
@@ -0,0 +1,735 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bitcoin::address::NetworkUnchecked;
|
||||
use bitcoin::{Address, Txid};
|
||||
use ini::Ini;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use stealth_model::error::AnalysisError;
|
||||
use stealth_model::gateway::{
|
||||
BlockchainGateway, DecodedTransaction, DescriptorType, ResolvedDescriptor, TxInputRef,
|
||||
TxOutput, Utxo, WalletHistory, WalletTxCategory, WalletTxEntry,
|
||||
};
|
||||
use stealth_model::types::btc_to_amount;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BitcoinCoreConfig {
|
||||
pub network: String,
|
||||
pub datadir: Option<PathBuf>,
|
||||
pub rpchost: String,
|
||||
pub rpcport: u16,
|
||||
pub rpcuser: Option<String>,
|
||||
pub rpcpassword: Option<String>,
|
||||
}
|
||||
|
||||
impl BitcoinCoreConfig {
|
||||
pub fn from_ini_file(path: impl AsRef<Path>) -> Result<Self, AnalysisError> {
|
||||
let path = path.as_ref();
|
||||
let ini = Ini::load_from_file(path)
|
||||
.map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?;
|
||||
let section = ini.section(Some("bitcoin")).ok_or_else(|| {
|
||||
AnalysisError::EnvironmentUnavailable("missing [bitcoin] section".into())
|
||||
})?;
|
||||
|
||||
let network = section
|
||||
.get("network")
|
||||
.map(|value| value.trim().to_lowercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "regtest".into());
|
||||
let datadir = section.get("datadir").and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else if Path::new(trimmed).is_absolute() {
|
||||
Some(PathBuf::from(trimmed))
|
||||
} else {
|
||||
Some(
|
||||
path.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(trimmed),
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
rpcport: section
|
||||
.get("rpcport")
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or_else(|| default_rpc_port(&network)),
|
||||
rpchost: section
|
||||
.get("rpchost")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "127.0.0.1".into()),
|
||||
rpcuser: section
|
||||
.get("rpcuser")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
rpcpassword: section
|
||||
.get("rpcpassword")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
network,
|
||||
datadir,
|
||||
})
|
||||
}
|
||||
|
||||
fn cookie_credentials(&self) -> Result<(String, String), AnalysisError> {
|
||||
let datadir = self.datadir.as_ref().ok_or_else(|| {
|
||||
AnalysisError::EnvironmentUnavailable("missing datadir for cookie auth".into())
|
||||
})?;
|
||||
let mut candidates = Vec::new();
|
||||
if self.network == "mainnet" {
|
||||
candidates.push(datadir.join(".cookie"));
|
||||
} else {
|
||||
candidates.push(datadir.join(&self.network).join(".cookie"));
|
||||
candidates.push(datadir.join(".cookie"));
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
if !candidate.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(creds) = read_cookie_file(&candidate) {
|
||||
return Ok(creds);
|
||||
}
|
||||
}
|
||||
|
||||
Err(AnalysisError::EnvironmentUnavailable(
|
||||
"could not locate a readable Bitcoin Core cookie file".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a Bitcoin Core `.cookie` file, returning `(user, password)`.
|
||||
///
|
||||
/// The cookie format is a single line of `__cookie__:hex_password`.
|
||||
pub fn read_cookie_file(path: &Path) -> Result<(String, String), AnalysisError> {
|
||||
let contents = fs::read_to_string(path).map_err(|e| {
|
||||
AnalysisError::EnvironmentUnavailable(format!(
|
||||
"cannot read cookie file {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let mut parts = contents.trim().splitn(2, ':');
|
||||
let user = parts
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AnalysisError::EnvironmentUnavailable(format!("invalid cookie file {}", path.display()))
|
||||
})?
|
||||
.to_string();
|
||||
let pass = parts
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AnalysisError::EnvironmentUnavailable(format!("invalid cookie file {}", path.display()))
|
||||
})?
|
||||
.to_string();
|
||||
Ok((user, pass))
|
||||
}
|
||||
|
||||
pub struct BitcoinCoreRpc {
|
||||
config: BitcoinCoreConfig,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl BitcoinCoreRpc {
|
||||
pub fn new(config: BitcoinCoreConfig) -> Result<Self, AnalysisError> {
|
||||
let client = Client::builder()
|
||||
.build()
|
||||
.map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?;
|
||||
Ok(Self { config, client })
|
||||
}
|
||||
|
||||
/// Construct a gateway from a URL and optional credentials.
|
||||
///
|
||||
/// This mirrors the env-var based configuration used by the HTTP
|
||||
/// API (`STEALTH_RPC_URL`, `STEALTH_RPC_USER`, `STEALTH_RPC_PASS`).
|
||||
pub fn from_url(
|
||||
url: &str,
|
||||
user: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<Self, AnalysisError> {
|
||||
let (host, port) = parse_host_port_from_url(url);
|
||||
let config = BitcoinCoreConfig {
|
||||
network: infer_network_from_port(port),
|
||||
datadir: None,
|
||||
rpchost: host,
|
||||
rpcport: port,
|
||||
rpcuser: user,
|
||||
rpcpassword: password,
|
||||
};
|
||||
Self::new(config)
|
||||
}
|
||||
|
||||
fn rpc_url(&self, wallet: Option<&str>) -> String {
|
||||
let base = format!("http://{}:{}", self.config.rpchost, self.config.rpcport);
|
||||
wallet
|
||||
.map(|wallet_name| format!("{base}/wallet/{}", urlencoding::encode(wallet_name)))
|
||||
.unwrap_or(base)
|
||||
}
|
||||
|
||||
fn credentials(&self) -> Result<(String, String), AnalysisError> {
|
||||
if let (Some(user), Some(password)) =
|
||||
(self.config.rpcuser.clone(), self.config.rpcpassword.clone())
|
||||
{
|
||||
Ok((user, password))
|
||||
} else {
|
||||
self.config.cookie_credentials()
|
||||
}
|
||||
}
|
||||
|
||||
fn call<T: DeserializeOwned>(
|
||||
&self,
|
||||
wallet: Option<&str>,
|
||||
method: &str,
|
||||
params: Vec<Value>,
|
||||
) -> Result<T, AnalysisError> {
|
||||
let (user, password) = self.credentials()?;
|
||||
let response = self
|
||||
.client
|
||||
.post(self.rpc_url(wallet))
|
||||
.basic_auth(user, Some(password))
|
||||
.json(&json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "stealth-rust",
|
||||
"method": method,
|
||||
"params": params,
|
||||
}))
|
||||
.send()
|
||||
.map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AnalysisError::EnvironmentUnavailable(format!(
|
||||
"rpc transport error: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let envelope = response
|
||||
.json::<JsonRpcEnvelope<T>>()
|
||||
.map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?;
|
||||
match (envelope.result, envelope.error) {
|
||||
(Some(result), None) => Ok(result),
|
||||
(_, Some(error)) => Err(AnalysisError::EnvironmentUnavailable(error.message)),
|
||||
_ => Err(AnalysisError::EnvironmentUnavailable(
|
||||
"rpc returned neither result nor error".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_history_for_wallet(&self, wallet_name: &str) -> Result<WalletHistory, AnalysisError> {
|
||||
let wallet_txs = self.list_transactions(wallet_name)?;
|
||||
let utxos = self.list_unspent(wallet_name)?;
|
||||
let mut txids = wallet_txs
|
||||
.iter()
|
||||
.map(|entry| entry.txid)
|
||||
.collect::<HashSet<_>>();
|
||||
txids.extend(utxos.iter().map(|utxo| utxo.txid));
|
||||
|
||||
let mut transactions = HashMap::new();
|
||||
let mut queue = txids.into_iter().collect::<Vec<_>>();
|
||||
while let Some(txid) = queue.pop() {
|
||||
if transactions.contains_key(&txid) {
|
||||
continue;
|
||||
}
|
||||
let tx = self.decode_transaction(txid)?;
|
||||
for input in &tx.vin {
|
||||
if !input.coinbase && !transactions.contains_key(&input.previous_txid) {
|
||||
queue.push(input.previous_txid);
|
||||
}
|
||||
}
|
||||
transactions.insert(txid, tx);
|
||||
}
|
||||
|
||||
Ok(WalletHistory {
|
||||
wallet_txs,
|
||||
utxos,
|
||||
transactions,
|
||||
internal_addresses: HashSet::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_transactions(&self, wallet_name: &str) -> Result<Vec<WalletTxEntry>, AnalysisError> {
|
||||
let entries = self.call::<Vec<ListTransactionEntry>>(
|
||||
Some(wallet_name),
|
||||
"listtransactions",
|
||||
vec![json!("*"), json!(10000), json!(0), json!(true)],
|
||||
)?;
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let address: Option<Address<NetworkUnchecked>> =
|
||||
entry.address.as_deref().and_then(|s| s.parse().ok());
|
||||
Ok(WalletTxEntry {
|
||||
txid: parse_txid(&entry.txid)?,
|
||||
address,
|
||||
category: match entry.category.as_deref() {
|
||||
Some("send") => WalletTxCategory::Send,
|
||||
Some("receive") => WalletTxCategory::Receive,
|
||||
_ => WalletTxCategory::Unknown,
|
||||
},
|
||||
amount: btc_to_amount(entry.amount.abs()),
|
||||
confirmations: entry.confirmations.unwrap_or_default(),
|
||||
blockheight: entry.blockheight.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn list_unspent(&self, wallet_name: &str) -> Result<Vec<Utxo>, AnalysisError> {
|
||||
let utxos = self.call::<Vec<ListUnspentEntry>>(
|
||||
Some(wallet_name),
|
||||
"listunspent",
|
||||
vec![json!(0), json!(9_999_999)],
|
||||
)?;
|
||||
utxos
|
||||
.into_iter()
|
||||
.map(|utxo| {
|
||||
let address: Option<Address<NetworkUnchecked>> =
|
||||
utxo.address.as_deref().and_then(|s| s.parse().ok());
|
||||
Ok(Utxo {
|
||||
txid: parse_txid(&utxo.txid)?,
|
||||
vout: utxo.vout,
|
||||
script_type: address
|
||||
.as_ref()
|
||||
.map(DescriptorType::infer_from_address)
|
||||
.unwrap_or(DescriptorType::Unknown),
|
||||
address,
|
||||
amount: btc_to_amount(utxo.amount),
|
||||
confirmations: utxo.confirmations.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode_transaction(&self, txid: Txid) -> Result<DecodedTransaction, AnalysisError> {
|
||||
let tx = self.call::<RawTransaction>(
|
||||
None,
|
||||
"getrawtransaction",
|
||||
vec![json!(txid.to_string()), json!(true)],
|
||||
)?;
|
||||
|
||||
Ok(DecodedTransaction {
|
||||
txid: parse_txid(&tx.txid)?,
|
||||
vin: tx
|
||||
.vin
|
||||
.into_iter()
|
||||
.map(|input| {
|
||||
Ok(TxInputRef {
|
||||
previous_txid: match &input.txid {
|
||||
Some(s) => parse_txid(s)?,
|
||||
// Bitcoin protocol: coinbase inputs reference all-zeros.
|
||||
None => parse_txid(
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
)
|
||||
.expect("zero txid is always valid"),
|
||||
},
|
||||
previous_vout: input.vout.unwrap_or_default(),
|
||||
sequence: input.sequence.unwrap_or(0xffff_ffff),
|
||||
coinbase: input.coinbase.is_some(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, AnalysisError>>()?,
|
||||
vout: tx
|
||||
.vout
|
||||
.into_iter()
|
||||
.map(|output| {
|
||||
let address: Option<Address<NetworkUnchecked>> = output
|
||||
.script_pub_key
|
||||
.address
|
||||
.or_else(|| {
|
||||
output
|
||||
.script_pub_key
|
||||
.addresses
|
||||
.and_then(|mut items| items.pop())
|
||||
})
|
||||
.and_then(|s| s.parse().ok());
|
||||
TxOutput {
|
||||
n: output.n,
|
||||
script_type: address
|
||||
.as_ref()
|
||||
.map(DescriptorType::infer_from_address)
|
||||
.or_else(|| {
|
||||
output
|
||||
.script_pub_key
|
||||
.script_type
|
||||
.as_deref()
|
||||
.map(descriptor_type_from_script_pub_key)
|
||||
})
|
||||
.unwrap_or(DescriptorType::Unknown),
|
||||
address,
|
||||
value: btc_to_amount(output.value),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
version: tx.version.unwrap_or(2),
|
||||
locktime: tx.locktime.unwrap_or_default(),
|
||||
vsize: tx.vsize.unwrap_or_default(),
|
||||
confirmations: tx.confirmations.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_watch_only_wallet(&self, wallet_name: &str) -> Result<(), AnalysisError> {
|
||||
let _ = self.call::<Value>(
|
||||
None,
|
||||
"createwallet",
|
||||
vec![
|
||||
json!(wallet_name),
|
||||
json!(true),
|
||||
json!(true),
|
||||
json!(""),
|
||||
json!(false),
|
||||
json!(true),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unload_wallet(&self, wallet_name: &str) {
|
||||
let _ = self.call::<Value>(None, "unloadwallet", vec![json!(wallet_name)]);
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockchainGateway for BitcoinCoreRpc {
|
||||
fn normalize_descriptor(&self, descriptor: &str) -> Result<String, AnalysisError> {
|
||||
let response =
|
||||
self.call::<DescriptorInfo>(None, "getdescriptorinfo", vec![json!(descriptor)])?;
|
||||
Ok(response.descriptor)
|
||||
}
|
||||
|
||||
fn derive_addresses(
|
||||
&self,
|
||||
descriptor: &ResolvedDescriptor,
|
||||
) -> Result<Vec<Address<NetworkUnchecked>>, AnalysisError> {
|
||||
let strings: Vec<String> = self.call(
|
||||
None,
|
||||
"deriveaddresses",
|
||||
vec![json!(descriptor.desc), json!([0, descriptor.range_end])],
|
||||
)?;
|
||||
strings
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
s.parse::<Address<NetworkUnchecked>>().map_err(|e| {
|
||||
AnalysisError::EnvironmentUnavailable(format!("invalid address '{s}': {e}"))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scan_descriptors(
|
||||
&self,
|
||||
descriptors: &[ResolvedDescriptor],
|
||||
) -> Result<WalletHistory, AnalysisError> {
|
||||
let wallet_name = format!(
|
||||
"_stealth_scan_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?
|
||||
.as_millis()
|
||||
);
|
||||
self.create_watch_only_wallet(&wallet_name)?;
|
||||
|
||||
// RAII guard: ensure the temporary wallet is always unloaded,
|
||||
// even if the body below returns an early error via `?`.
|
||||
let _guard = WalletGuard {
|
||||
rpc: self,
|
||||
name: &wallet_name,
|
||||
};
|
||||
|
||||
let imports = descriptors
|
||||
.iter()
|
||||
.map(|descriptor| {
|
||||
let is_ranged = descriptor.desc.contains('*');
|
||||
let mut entry = json!({
|
||||
"desc": descriptor.desc,
|
||||
"timestamp": 0,
|
||||
"internal": descriptor.internal,
|
||||
"active": is_ranged && descriptor.active,
|
||||
});
|
||||
if is_ranged {
|
||||
entry["range"] = json!([0, descriptor.range_end]);
|
||||
}
|
||||
entry
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let import_results = self.call::<Vec<ImportResult>>(
|
||||
Some(&wallet_name),
|
||||
"importdescriptors",
|
||||
vec![json!(imports)],
|
||||
)?;
|
||||
if import_results.iter().any(|result| !result.success) {
|
||||
let errors: Vec<_> = import_results
|
||||
.iter()
|
||||
.filter(|r| !r.success)
|
||||
.filter_map(|r| r.error.as_ref().map(|e| e.message.as_str()))
|
||||
.collect();
|
||||
return Err(AnalysisError::EnvironmentUnavailable(format!(
|
||||
"descriptor import failed: {}",
|
||||
errors.join("; ")
|
||||
)));
|
||||
}
|
||||
|
||||
let mut history = self.load_history_for_wallet(&wallet_name)?;
|
||||
|
||||
// Derive internal (change) addresses so TxGraph can correctly
|
||||
// mark them as internal in AddressInfo.
|
||||
let mut internal_addresses = HashSet::new();
|
||||
for desc in descriptors {
|
||||
if desc.internal {
|
||||
if let Ok(addrs) = self.derive_addresses(desc) {
|
||||
internal_addresses.extend(addrs);
|
||||
}
|
||||
}
|
||||
}
|
||||
history.internal_addresses = internal_addresses;
|
||||
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
fn list_wallet_descriptors(
|
||||
&self,
|
||||
wallet_name: &str,
|
||||
) -> Result<Vec<ResolvedDescriptor>, AnalysisError> {
|
||||
let response =
|
||||
self.call::<ListDescriptorsResponse>(Some(wallet_name), "listdescriptors", Vec::new())?;
|
||||
Ok(response
|
||||
.descriptors
|
||||
.into_iter()
|
||||
.map(|descriptor| ResolvedDescriptor {
|
||||
desc: descriptor.desc,
|
||||
internal: descriptor.internal.unwrap_or(false),
|
||||
active: descriptor.active.unwrap_or(true),
|
||||
range_end: descriptor
|
||||
.range
|
||||
.map(|range| match range {
|
||||
DescriptorRange::Single(value) => value,
|
||||
DescriptorRange::Pair([_, end]) => end,
|
||||
})
|
||||
.unwrap_or(999),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn scan_wallet(&self, wallet_name: &str) -> Result<WalletHistory, AnalysisError> {
|
||||
let mut history = self.load_history_for_wallet(wallet_name)?;
|
||||
|
||||
// Derive internal (change) addresses so TxGraph can correctly
|
||||
// mark them in AddressInfo, mirroring the scan_descriptors path.
|
||||
if let Ok(descriptors) = self.list_wallet_descriptors(wallet_name) {
|
||||
let mut internal_addresses = HashSet::new();
|
||||
for desc in &descriptors {
|
||||
if desc.internal {
|
||||
if let Ok(addrs) = self.derive_addresses(desc) {
|
||||
internal_addresses.extend(addrs);
|
||||
}
|
||||
}
|
||||
}
|
||||
history.internal_addresses = internal_addresses;
|
||||
}
|
||||
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
fn known_wallet_txids(&self, wallet_names: &[String]) -> Result<HashSet<Txid>, AnalysisError> {
|
||||
let mut txids = HashSet::new();
|
||||
for wallet_name in wallet_names {
|
||||
txids.extend(
|
||||
self.list_transactions(wallet_name)?
|
||||
.into_iter()
|
||||
.map(|entry| entry.txid),
|
||||
);
|
||||
}
|
||||
Ok(txids)
|
||||
}
|
||||
|
||||
fn get_transaction(&self, txid: Txid) -> Result<DecodedTransaction, AnalysisError> {
|
||||
self.decode_transaction(txid)
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard that calls `unloadwallet` when dropped, ensuring cleanup
|
||||
/// even when an early `?` return skips the normal unload path.
|
||||
struct WalletGuard<'a> {
|
||||
rpc: &'a BitcoinCoreRpc,
|
||||
name: &'a str,
|
||||
}
|
||||
|
||||
impl Drop for WalletGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.rpc.unload_wallet(self.name);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_txid(s: &str) -> Result<Txid, AnalysisError> {
|
||||
s.parse::<Txid>()
|
||||
.map_err(|e| AnalysisError::EnvironmentUnavailable(format!("invalid txid '{s}': {e}")))
|
||||
}
|
||||
|
||||
fn default_rpc_port(network: &str) -> u16 {
|
||||
match network {
|
||||
"mainnet" => 8332,
|
||||
"testnet" => 18332,
|
||||
"signet" => 38332,
|
||||
_ => 18443,
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor_type_from_script_pub_key(script_type: &str) -> DescriptorType {
|
||||
match script_type {
|
||||
"witness_v0_keyhash" => DescriptorType::P2wpkh,
|
||||
"witness_v1_taproot" => DescriptorType::P2tr,
|
||||
"scripthash" => DescriptorType::P2sh,
|
||||
"pubkeyhash" => DescriptorType::P2pkh,
|
||||
_ => DescriptorType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_host_port_from_url(url: &str) -> (String, u16) {
|
||||
let without_scheme = url
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| url.strip_prefix("https://"))
|
||||
.unwrap_or(url);
|
||||
let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
|
||||
match authority.rsplit_once(':') {
|
||||
Some((host, port_str)) => {
|
||||
let port = port_str.parse::<u16>().unwrap_or(8332);
|
||||
(host.to_owned(), port)
|
||||
}
|
||||
None => (authority.to_owned(), 8332),
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_network_from_port(port: u16) -> String {
|
||||
match port {
|
||||
8332 => "mainnet",
|
||||
18332 => "testnet",
|
||||
38332 => "signet",
|
||||
18443 => "regtest",
|
||||
_ => "regtest",
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcEnvelope<T> {
|
||||
result: Option<T>,
|
||||
error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DescriptorInfo {
|
||||
descriptor: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ImportResult {
|
||||
success: bool,
|
||||
#[serde(default)]
|
||||
error: Option<ImportError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ImportError {
|
||||
#[serde(default)]
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListDescriptorsResponse {
|
||||
descriptors: Vec<DescriptorRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DescriptorRecord {
|
||||
desc: String,
|
||||
internal: Option<bool>,
|
||||
active: Option<bool>,
|
||||
range: Option<DescriptorRange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum DescriptorRange {
|
||||
Single(u32),
|
||||
Pair([u32; 2]),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListTransactionEntry {
|
||||
txid: String,
|
||||
address: Option<String>,
|
||||
category: Option<String>,
|
||||
amount: f64,
|
||||
confirmations: Option<u32>,
|
||||
blockheight: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListUnspentEntry {
|
||||
txid: String,
|
||||
vout: u32,
|
||||
address: Option<String>,
|
||||
amount: f64,
|
||||
confirmations: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawTransaction {
|
||||
txid: String,
|
||||
vin: Vec<RawVin>,
|
||||
vout: Vec<RawVout>,
|
||||
version: Option<i32>,
|
||||
locktime: Option<u32>,
|
||||
vsize: Option<u32>,
|
||||
confirmations: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawVin {
|
||||
txid: Option<String>,
|
||||
vout: Option<u32>,
|
||||
coinbase: Option<String>,
|
||||
sequence: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawVout {
|
||||
value: f64,
|
||||
n: u32,
|
||||
#[serde(rename = "scriptPubKey")]
|
||||
script_pub_key: RawScriptPubKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawScriptPubKey {
|
||||
address: Option<String>,
|
||||
addresses: Option<Vec<String>>,
|
||||
#[serde(rename = "type")]
|
||||
script_type: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::default_rpc_port;
|
||||
|
||||
#[test]
|
||||
fn network_defaults_match_bitcoin_core_ports() {
|
||||
assert_eq!(default_rpc_port("regtest"), 18443);
|
||||
assert_eq!(default_rpc_port("testnet"), 18332);
|
||||
assert_eq!(default_rpc_port("signet"), 38332);
|
||||
assert_eq!(default_rpc_port("mainnet"), 8332);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user