use bitcoin::{Address, Amount, Network, Txid};
use bitcoincore_rpc::json::{
GetBlockchainInfoResult, GetNetworkInfoResult, GetRawTransactionResult, GetTransactionResult,
};
use bitcoincore_rpc::{Auth, Client, RpcApi};
use serde::Serialize;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use crate::error::{BitcoinError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BitcoinNetwork {
Mainnet,
Testnet,
Testnet4,
Regtest,
Signet,
}
impl From<BitcoinNetwork> for Network {
fn from(network: BitcoinNetwork) -> Self {
match network {
BitcoinNetwork::Mainnet => Network::Bitcoin,
BitcoinNetwork::Testnet => Network::Testnet,
BitcoinNetwork::Testnet4 => Network::Testnet,
BitcoinNetwork::Regtest => Network::Regtest,
BitcoinNetwork::Signet => Network::Signet,
}
}
}
#[derive(Debug, Clone)]
pub struct ReconnectConfig {
pub max_retries: u32,
pub initial_delay: Duration,
pub max_delay: Duration,
pub backoff_multiplier: f64,
}
impl Default for ReconnectConfig {
fn default() -> Self {
Self {
max_retries: 5,
initial_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(30),
backoff_multiplier: 2.0,
}
}
}
#[derive(Clone)]
struct ConnectionParams {
url: String,
user: String,
password: String,
}
pub struct BitcoinClient {
client: Arc<RwLock<Client>>,
network: BitcoinNetwork,
connection_params: ConnectionParams,
reconnect_config: ReconnectConfig,
}
impl BitcoinClient {
pub fn new(url: &str, user: &str, password: &str, network: BitcoinNetwork) -> Result<Self> {
Self::with_config(url, user, password, network, ReconnectConfig::default())
}
pub fn with_config(
url: &str,
user: &str,
password: &str,
network: BitcoinNetwork,
reconnect_config: ReconnectConfig,
) -> Result<Self> {
let client = Client::new(url, Auth::UserPass(user.to_string(), password.to_string()))?;
tracing::info!(url = url, network = ?network, "Bitcoin RPC client connected");
Ok(Self {
client: Arc::new(RwLock::new(client)),
network,
connection_params: ConnectionParams {
url: url.to_string(),
user: user.to_string(),
password: password.to_string(),
},
reconnect_config,
})
}
fn reconnect(&self) -> Result<()> {
let params = &self.connection_params;
let new_client = Client::new(
¶ms.url,
Auth::UserPass(params.user.clone(), params.password.clone()),
)?;
let mut client = self.client.write().unwrap();
*client = new_client;
tracing::info!("Bitcoin RPC client reconnected");
Ok(())
}
fn with_retry<T, F>(&self, operation: F) -> Result<T>
where
F: Fn(&Client) -> std::result::Result<T, bitcoincore_rpc::Error>,
{
let mut last_error = None;
let mut delay = self.reconnect_config.initial_delay;
for attempt in 0..=self.reconnect_config.max_retries {
let client = self.client.read().unwrap();
match operation(&client) {
Ok(result) => return Ok(result),
Err(e) => {
last_error = Some(e);
drop(client);
if attempt < self.reconnect_config.max_retries {
tracing::warn!(
attempt = attempt + 1,
max_retries = self.reconnect_config.max_retries,
delay_ms = delay.as_millis(),
"Bitcoin RPC failed, attempting reconnection"
);
std::thread::sleep(delay);
if let Err(reconnect_err) = self.reconnect() {
tracing::warn!(error = %reconnect_err, "Reconnection failed");
}
delay = std::cmp::min(
Duration::from_secs_f64(
delay.as_secs_f64() * self.reconnect_config.backoff_multiplier,
),
self.reconnect_config.max_delay,
);
}
}
}
}
Err(BitcoinError::Rpc(last_error.unwrap()))
}
pub fn network(&self) -> BitcoinNetwork {
self.network
}
pub fn health_check(&self) -> Result<bool> {
match self.with_retry(|c| c.get_blockchain_info()) {
Ok(_) => Ok(true),
Err(e) => {
tracing::warn!(error = %e, "Bitcoin RPC health check failed");
Ok(false)
}
}
}
pub fn get_blockchain_info(&self) -> Result<GetBlockchainInfoResult> {
self.with_retry(|c| c.get_blockchain_info())
}
pub fn get_network_info(&self) -> Result<GetNetworkInfoResult> {
self.with_retry(|c| c.get_network_info())
}
pub fn get_block_height(&self) -> Result<u64> {
let info = self.with_retry(|c| c.get_blockchain_info())?;
Ok(info.blocks)
}
pub fn get_best_block_hash(&self) -> Result<bitcoin::BlockHash> {
self.with_retry(|c| c.get_best_block_hash())
}
pub fn get_new_address(
&self,
label: Option<&str>,
) -> Result<Address<bitcoin::address::NetworkUnchecked>> {
self.with_retry(|c| c.get_new_address(label, None))
}
pub fn get_received_by_address(
&self,
address: &Address,
min_confirmations: Option<u32>,
) -> Result<Amount> {
self.with_retry(|c| c.get_received_by_address(address, min_confirmations))
}
pub fn get_transaction(&self, txid: &Txid) -> Result<GetTransactionResult> {
self.with_retry(|c| c.get_transaction(txid, None))
}
pub fn get_raw_transaction(&self, txid: &Txid) -> Result<GetRawTransactionResult> {
self.with_retry(|c| c.get_raw_transaction_info(txid, None))
}
pub fn list_unspent(
&self,
min_conf: Option<usize>,
max_conf: Option<usize>,
addresses: Option<&[&Address<bitcoin::address::NetworkChecked>]>,
) -> Result<Vec<bitcoincore_rpc::json::ListUnspentResultEntry>> {
self.with_retry(|c| c.list_unspent(min_conf, max_conf, addresses, None, None))
}
pub fn get_balance(&self) -> Result<Amount> {
self.with_retry(|c| c.get_balance(None, None))
}
pub fn validate_address(&self, address: &str) -> Result<AddressValidation> {
let parsed = address
.parse::<Address<bitcoin::address::NetworkUnchecked>>()
.map_err(|e| BitcoinError::InvalidAddress(e.to_string()));
match parsed {
Ok(_addr) => Ok(AddressValidation {
is_valid: true,
is_mine: false, is_script: address.starts_with("3") || address.starts_with("bc1q"),
}),
Err(_) => Ok(AddressValidation {
is_valid: false,
is_mine: false,
is_script: false,
}),
}
}
pub fn get_mempool_info(&self) -> Result<bitcoincore_rpc::json::GetMempoolInfoResult> {
self.with_retry(|c| c.get_mempool_info())
}
pub fn estimate_smart_fee(&self, conf_target: u16) -> Result<Option<f64>> {
let result = self.with_retry(|c| c.estimate_smart_fee(conf_target, None))?;
Ok(result.fee_rate.map(|amt| {
amt.to_sat() as f64 / 1000.0
}))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AddressValidation {
pub is_valid: bool,
pub is_mine: bool,
pub is_script: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct NodeStatus {
pub connected: bool,
pub block_height: u64,
pub network: String,
pub version: u64,
pub connections: usize,
pub mempool_size: u64,
}
impl BitcoinClient {
pub fn get_node_status(&self) -> Result<NodeStatus> {
let blockchain_info = self.with_retry(|c| c.get_blockchain_info())?;
let network_info = self.with_retry(|c| c.get_network_info())?;
let mempool_info = self.with_retry(|c| c.get_mempool_info())?;
Ok(NodeStatus {
connected: true,
block_height: blockchain_info.blocks,
network: blockchain_info.chain.to_string(),
version: network_info.version as u64,
connections: network_info.connections,
mempool_size: mempool_info.size as u64,
})
}
pub fn list_since_block(
&self,
block_hash: Option<&bitcoin::BlockHash>,
target_confirmations: Option<usize>,
) -> Result<ListSinceBlockResult> {
let result =
self.with_retry(|c| c.list_since_block(block_hash, target_confirmations, None, None))?;
Ok(ListSinceBlockResult {
transactions: result
.transactions
.into_iter()
.map(|tx| TransactionInfo {
txid: tx.info.txid,
address: tx.detail.address.map(|a| a.assume_checked().to_string()),
category: format!("{:?}", tx.detail.category),
amount: tx.detail.amount.to_sat(),
confirmations: tx.info.confirmations,
block_hash: tx.info.blockhash,
block_time: tx.info.blocktime,
time: tx.info.time,
})
.collect(),
last_block: result.lastblock,
})
}
pub fn get_address_info(&self, address: &str) -> Result<AddressInfo> {
let parsed: Address<bitcoin::address::NetworkUnchecked> =
address.parse().map_err(|e: bitcoin::address::ParseError| {
BitcoinError::InvalidAddress(e.to_string())
})?;
let checked_addr = parsed.assume_checked();
let received = self.with_retry(|c| c.get_received_by_address(&checked_addr, Some(0)))?;
let received_confirmed =
self.with_retry(|c| c.get_received_by_address(&checked_addr, Some(1)))?;
Ok(AddressInfo {
address: address.to_string(),
is_valid: true,
total_received_sats: received.to_sat(),
confirmed_received_sats: received_confirmed.to_sat(),
unconfirmed_sats: received
.to_sat()
.saturating_sub(received_confirmed.to_sat()),
})
}
pub fn send_raw_transaction(&self, tx_hex: &str) -> Result<Txid> {
let tx_hex_owned = tx_hex.to_string();
self.with_retry(|c| c.send_raw_transaction(tx_hex_owned.clone()))
}
pub fn get_block_hash(&self, height: u64) -> Result<bitcoin::BlockHash> {
self.with_retry(|c| c.get_block_hash(height))
}
pub fn test_mempool_accept(&self, tx_hex: &str) -> Result<bool> {
let rawtxs = vec![tx_hex.to_string()];
let results = self.with_retry(|c| c.test_mempool_accept(&rawtxs))?;
Ok(results.first().map(|r| r.allowed).unwrap_or(false))
}
pub fn generate_to_address(
&self,
blocks: u64,
address: &bitcoin::Address,
) -> Result<Vec<bitcoin::BlockHash>> {
self.with_retry(|c| c.generate_to_address(blocks, address))
}
pub fn send_to_address(
&self,
address: &bitcoin::Address,
amount: bitcoin::Amount,
) -> Result<Txid> {
self.with_retry(|c| c.send_to_address(address, amount, None, None, None, None, None, None))
}
pub fn invalidate_block(&self, block_hash: &bitcoin::BlockHash) -> Result<()> {
self.with_retry(|c| c.invalidate_block(block_hash))
}
pub fn reconsider_block(&self, block_hash: &bitcoin::BlockHash) -> Result<()> {
self.with_retry(|c| c.reconsider_block(block_hash))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ListSinceBlockResult {
pub transactions: Vec<TransactionInfo>,
pub last_block: bitcoin::BlockHash,
}
#[derive(Debug, Clone, Serialize)]
pub struct TransactionInfo {
pub txid: Txid,
pub address: Option<String>,
pub category: String,
pub amount: i64,
pub confirmations: i32,
pub block_hash: Option<bitcoin::BlockHash>,
pub block_time: Option<u64>,
pub time: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct AddressInfo {
pub address: String,
pub is_valid: bool,
pub total_received_sats: u64,
pub confirmed_received_sats: u64,
pub unconfirmed_sats: u64,
}