kaccy_bitcoin/
client.rs

1//! Bitcoin Core RPC client
2
3use bitcoin::{Address, Amount, Network, Txid};
4use bitcoincore_rpc::json::{
5    GetBlockchainInfoResult, GetNetworkInfoResult, GetRawTransactionResult, GetTransactionResult,
6};
7use bitcoincore_rpc::{Auth, Client, RpcApi};
8use serde::Serialize;
9use std::sync::{Arc, RwLock};
10use std::time::Duration;
11
12use crate::error::{BitcoinError, Result};
13
14/// Bitcoin network configuration
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum BitcoinNetwork {
17    Mainnet,
18    Testnet,
19    /// Testnet4 (when available, currently maps to Testnet)
20    Testnet4,
21    Regtest,
22    Signet,
23}
24
25impl From<BitcoinNetwork> for Network {
26    fn from(network: BitcoinNetwork) -> Self {
27        match network {
28            BitcoinNetwork::Mainnet => Network::Bitcoin,
29            BitcoinNetwork::Testnet => Network::Testnet,
30            // Testnet4 not yet available in bitcoin crate, map to Testnet for now
31            BitcoinNetwork::Testnet4 => Network::Testnet,
32            BitcoinNetwork::Regtest => Network::Regtest,
33            BitcoinNetwork::Signet => Network::Signet,
34        }
35    }
36}
37
38/// Configuration for automatic reconnection
39#[derive(Debug, Clone)]
40pub struct ReconnectConfig {
41    /// Maximum retry attempts
42    pub max_retries: u32,
43    /// Initial delay between retries
44    pub initial_delay: Duration,
45    /// Maximum delay between retries
46    pub max_delay: Duration,
47    /// Backoff multiplier
48    pub backoff_multiplier: f64,
49}
50
51impl Default for ReconnectConfig {
52    fn default() -> Self {
53        Self {
54            max_retries: 5,
55            initial_delay: Duration::from_millis(500),
56            max_delay: Duration::from_secs(30),
57            backoff_multiplier: 2.0,
58        }
59    }
60}
61
62/// Connection parameters for client recreation
63#[derive(Clone)]
64struct ConnectionParams {
65    url: String,
66    user: String,
67    password: String,
68}
69
70/// Bitcoin Core RPC client wrapper with automatic reconnection
71///
72/// # Examples
73///
74/// ```no_run
75/// use kaccy_bitcoin::{BitcoinClient, BitcoinNetwork};
76///
77/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
78/// let client = BitcoinClient::new(
79///     "http://localhost:8332",
80///     "rpcuser",
81///     "rpcpassword",
82///     BitcoinNetwork::Testnet,
83/// )?;
84///
85/// // Check connection health
86/// let is_healthy = client.health_check()?;
87/// println!("Bitcoin node healthy: {}", is_healthy);
88/// # Ok(())
89/// # }
90/// ```
91pub struct BitcoinClient {
92    client: Arc<RwLock<Client>>,
93    network: BitcoinNetwork,
94    connection_params: ConnectionParams,
95    reconnect_config: ReconnectConfig,
96}
97
98impl BitcoinClient {
99    /// Create a new Bitcoin RPC client
100    ///
101    /// # Examples
102    ///
103    /// ```no_run
104    /// use kaccy_bitcoin::{BitcoinClient, BitcoinNetwork};
105    ///
106    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
107    /// let client = BitcoinClient::new(
108    ///     "http://localhost:18443",
109    ///     "user",
110    ///     "pass",
111    ///     BitcoinNetwork::Regtest,
112    /// )?;
113    /// # Ok(())
114    /// # }
115    /// ```
116    pub fn new(url: &str, user: &str, password: &str, network: BitcoinNetwork) -> Result<Self> {
117        Self::with_config(url, user, password, network, ReconnectConfig::default())
118    }
119
120    /// Create a new Bitcoin RPC client with custom reconnection config
121    pub fn with_config(
122        url: &str,
123        user: &str,
124        password: &str,
125        network: BitcoinNetwork,
126        reconnect_config: ReconnectConfig,
127    ) -> Result<Self> {
128        let client = Client::new(url, Auth::UserPass(user.to_string(), password.to_string()))?;
129
130        tracing::info!(url = url, network = ?network, "Bitcoin RPC client connected");
131
132        Ok(Self {
133            client: Arc::new(RwLock::new(client)),
134            network,
135            connection_params: ConnectionParams {
136                url: url.to_string(),
137                user: user.to_string(),
138                password: password.to_string(),
139            },
140            reconnect_config,
141        })
142    }
143
144    /// Try to reconnect to Bitcoin Core
145    fn reconnect(&self) -> Result<()> {
146        let params = &self.connection_params;
147        let new_client = Client::new(
148            &params.url,
149            Auth::UserPass(params.user.clone(), params.password.clone()),
150        )?;
151
152        let mut client = self.client.write().unwrap();
153        *client = new_client;
154
155        tracing::info!("Bitcoin RPC client reconnected");
156        Ok(())
157    }
158
159    /// Execute an RPC operation with automatic retry on connection failure
160    fn with_retry<T, F>(&self, operation: F) -> Result<T>
161    where
162        F: Fn(&Client) -> std::result::Result<T, bitcoincore_rpc::Error>,
163    {
164        let mut last_error = None;
165        let mut delay = self.reconnect_config.initial_delay;
166
167        for attempt in 0..=self.reconnect_config.max_retries {
168            let client = self.client.read().unwrap();
169            match operation(&client) {
170                Ok(result) => return Ok(result),
171                Err(e) => {
172                    last_error = Some(e);
173                    drop(client); // Release the read lock
174
175                    if attempt < self.reconnect_config.max_retries {
176                        tracing::warn!(
177                            attempt = attempt + 1,
178                            max_retries = self.reconnect_config.max_retries,
179                            delay_ms = delay.as_millis(),
180                            "Bitcoin RPC failed, attempting reconnection"
181                        );
182
183                        std::thread::sleep(delay);
184
185                        // Try to reconnect
186                        if let Err(reconnect_err) = self.reconnect() {
187                            tracing::warn!(error = %reconnect_err, "Reconnection failed");
188                        }
189
190                        // Exponential backoff
191                        delay = std::cmp::min(
192                            Duration::from_secs_f64(
193                                delay.as_secs_f64() * self.reconnect_config.backoff_multiplier,
194                            ),
195                            self.reconnect_config.max_delay,
196                        );
197                    }
198                }
199            }
200        }
201
202        Err(BitcoinError::Rpc(last_error.unwrap()))
203    }
204
205    /// Get the configured network
206    pub fn network(&self) -> BitcoinNetwork {
207        self.network
208    }
209
210    /// Check if the connection is healthy
211    pub fn health_check(&self) -> Result<bool> {
212        match self.with_retry(|c| c.get_blockchain_info()) {
213            Ok(_) => Ok(true),
214            Err(e) => {
215                tracing::warn!(error = %e, "Bitcoin RPC health check failed");
216                Ok(false)
217            }
218        }
219    }
220
221    /// Get blockchain info
222    pub fn get_blockchain_info(&self) -> Result<GetBlockchainInfoResult> {
223        self.with_retry(|c| c.get_blockchain_info())
224    }
225
226    /// Get network info
227    pub fn get_network_info(&self) -> Result<GetNetworkInfoResult> {
228        self.with_retry(|c| c.get_network_info())
229    }
230
231    /// Get current block height
232    pub fn get_block_height(&self) -> Result<u64> {
233        let info = self.with_retry(|c| c.get_blockchain_info())?;
234        Ok(info.blocks)
235    }
236
237    /// Get best block hash
238    pub fn get_best_block_hash(&self) -> Result<bitcoin::BlockHash> {
239        self.with_retry(|c| c.get_best_block_hash())
240    }
241
242    /// Generate a new address
243    pub fn get_new_address(
244        &self,
245        label: Option<&str>,
246    ) -> Result<Address<bitcoin::address::NetworkUnchecked>> {
247        self.with_retry(|c| c.get_new_address(label, None))
248    }
249
250    /// Get amount received by address
251    pub fn get_received_by_address(
252        &self,
253        address: &Address,
254        min_confirmations: Option<u32>,
255    ) -> Result<Amount> {
256        self.with_retry(|c| c.get_received_by_address(address, min_confirmations))
257    }
258
259    /// Get transaction by ID
260    pub fn get_transaction(&self, txid: &Txid) -> Result<GetTransactionResult> {
261        self.with_retry(|c| c.get_transaction(txid, None))
262    }
263
264    /// Get raw transaction
265    pub fn get_raw_transaction(&self, txid: &Txid) -> Result<GetRawTransactionResult> {
266        self.with_retry(|c| c.get_raw_transaction_info(txid, None))
267    }
268
269    /// Get unspent transactions for an address
270    pub fn list_unspent(
271        &self,
272        min_conf: Option<usize>,
273        max_conf: Option<usize>,
274        addresses: Option<&[&Address<bitcoin::address::NetworkChecked>]>,
275    ) -> Result<Vec<bitcoincore_rpc::json::ListUnspentResultEntry>> {
276        self.with_retry(|c| c.list_unspent(min_conf, max_conf, addresses, None, None))
277    }
278
279    /// Get wallet balance
280    pub fn get_balance(&self) -> Result<Amount> {
281        self.with_retry(|c| c.get_balance(None, None))
282    }
283
284    /// Validate an address (basic validation without RPC)
285    pub fn validate_address(&self, address: &str) -> Result<AddressValidation> {
286        // Parse address to validate format
287        let parsed = address
288            .parse::<Address<bitcoin::address::NetworkUnchecked>>()
289            .map_err(|e| BitcoinError::InvalidAddress(e.to_string()));
290
291        match parsed {
292            Ok(_addr) => Ok(AddressValidation {
293                is_valid: true,
294                is_mine: false, // Would need wallet check
295                is_script: address.starts_with("3") || address.starts_with("bc1q"),
296            }),
297            Err(_) => Ok(AddressValidation {
298                is_valid: false,
299                is_mine: false,
300                is_script: false,
301            }),
302        }
303    }
304
305    /// Get mempool info
306    pub fn get_mempool_info(&self) -> Result<bitcoincore_rpc::json::GetMempoolInfoResult> {
307        self.with_retry(|c| c.get_mempool_info())
308    }
309
310    /// Estimate smart fee (sats/vB)
311    pub fn estimate_smart_fee(&self, conf_target: u16) -> Result<Option<f64>> {
312        let result = self.with_retry(|c| c.estimate_smart_fee(conf_target, None))?;
313        Ok(result.fee_rate.map(|amt| {
314            // Convert BTC/kB to sat/vB
315            amt.to_sat() as f64 / 1000.0
316        }))
317    }
318}
319
320/// Address validation result
321#[derive(Debug, Clone, Serialize)]
322pub struct AddressValidation {
323    pub is_valid: bool,
324    pub is_mine: bool,
325    pub is_script: bool,
326}
327
328/// Summary of node status
329#[derive(Debug, Clone, Serialize)]
330pub struct NodeStatus {
331    pub connected: bool,
332    pub block_height: u64,
333    pub network: String,
334    pub version: u64,
335    pub connections: usize,
336    pub mempool_size: u64,
337}
338
339impl BitcoinClient {
340    /// Get comprehensive node status
341    pub fn get_node_status(&self) -> Result<NodeStatus> {
342        let blockchain_info = self.with_retry(|c| c.get_blockchain_info())?;
343        let network_info = self.with_retry(|c| c.get_network_info())?;
344        let mempool_info = self.with_retry(|c| c.get_mempool_info())?;
345
346        Ok(NodeStatus {
347            connected: true,
348            block_height: blockchain_info.blocks,
349            network: blockchain_info.chain.to_string(),
350            version: network_info.version as u64,
351            connections: network_info.connections,
352            mempool_size: mempool_info.size as u64,
353        })
354    }
355
356    /// List transactions since a specific block
357    pub fn list_since_block(
358        &self,
359        block_hash: Option<&bitcoin::BlockHash>,
360        target_confirmations: Option<usize>,
361    ) -> Result<ListSinceBlockResult> {
362        let result =
363            self.with_retry(|c| c.list_since_block(block_hash, target_confirmations, None, None))?;
364
365        Ok(ListSinceBlockResult {
366            transactions: result
367                .transactions
368                .into_iter()
369                .map(|tx| TransactionInfo {
370                    txid: tx.info.txid,
371                    address: tx.detail.address.map(|a| a.assume_checked().to_string()),
372                    category: format!("{:?}", tx.detail.category),
373                    amount: tx.detail.amount.to_sat(),
374                    confirmations: tx.info.confirmations,
375                    block_hash: tx.info.blockhash,
376                    block_time: tx.info.blocktime,
377                    time: tx.info.time,
378                })
379                .collect(),
380            last_block: result.lastblock,
381        })
382    }
383
384    /// Get detailed address info including balance
385    pub fn get_address_info(&self, address: &str) -> Result<AddressInfo> {
386        // Parse and validate address
387        let parsed: Address<bitcoin::address::NetworkUnchecked> =
388            address.parse().map_err(|e: bitcoin::address::ParseError| {
389                BitcoinError::InvalidAddress(e.to_string())
390            })?;
391
392        let checked_addr = parsed.assume_checked();
393
394        // Get received amount
395        let received = self.with_retry(|c| c.get_received_by_address(&checked_addr, Some(0)))?;
396        let received_confirmed =
397            self.with_retry(|c| c.get_received_by_address(&checked_addr, Some(1)))?;
398
399        Ok(AddressInfo {
400            address: address.to_string(),
401            is_valid: true,
402            total_received_sats: received.to_sat(),
403            confirmed_received_sats: received_confirmed.to_sat(),
404            unconfirmed_sats: received
405                .to_sat()
406                .saturating_sub(received_confirmed.to_sat()),
407        })
408    }
409
410    /// Send raw transaction to the network
411    pub fn send_raw_transaction(&self, tx_hex: &str) -> Result<Txid> {
412        let tx_hex_owned = tx_hex.to_string();
413        self.with_retry(|c| c.send_raw_transaction(tx_hex_owned.clone()))
414    }
415
416    /// Get block by height
417    pub fn get_block_hash(&self, height: u64) -> Result<bitcoin::BlockHash> {
418        self.with_retry(|c| c.get_block_hash(height))
419    }
420
421    /// Test mempool accept for a transaction
422    pub fn test_mempool_accept(&self, tx_hex: &str) -> Result<bool> {
423        let rawtxs = vec![tx_hex.to_string()];
424        let results = self.with_retry(|c| c.test_mempool_accept(&rawtxs))?;
425        Ok(results.first().map(|r| r.allowed).unwrap_or(false))
426    }
427
428    /// Generate blocks to an address (regtest only)
429    pub fn generate_to_address(
430        &self,
431        blocks: u64,
432        address: &bitcoin::Address,
433    ) -> Result<Vec<bitcoin::BlockHash>> {
434        self.with_retry(|c| c.generate_to_address(blocks, address))
435    }
436
437    /// Send to an address
438    pub fn send_to_address(
439        &self,
440        address: &bitcoin::Address,
441        amount: bitcoin::Amount,
442    ) -> Result<Txid> {
443        self.with_retry(|c| c.send_to_address(address, amount, None, None, None, None, None, None))
444    }
445
446    /// Invalidate a block (regtest only)
447    pub fn invalidate_block(&self, block_hash: &bitcoin::BlockHash) -> Result<()> {
448        self.with_retry(|c| c.invalidate_block(block_hash))
449    }
450
451    /// Reconsider a block (regtest only)
452    pub fn reconsider_block(&self, block_hash: &bitcoin::BlockHash) -> Result<()> {
453        self.with_retry(|c| c.reconsider_block(block_hash))
454    }
455}
456
457/// Result from list_since_block
458#[derive(Debug, Clone, Serialize)]
459pub struct ListSinceBlockResult {
460    pub transactions: Vec<TransactionInfo>,
461    pub last_block: bitcoin::BlockHash,
462}
463
464/// Transaction info from list_since_block
465#[derive(Debug, Clone, Serialize)]
466pub struct TransactionInfo {
467    pub txid: Txid,
468    pub address: Option<String>,
469    pub category: String,
470    pub amount: i64,
471    pub confirmations: i32,
472    pub block_hash: Option<bitcoin::BlockHash>,
473    pub block_time: Option<u64>,
474    pub time: u64,
475}
476
477/// Detailed address information
478#[derive(Debug, Clone, Serialize)]
479pub struct AddressInfo {
480    pub address: String,
481    pub is_valid: bool,
482    pub total_received_sats: u64,
483    pub confirmed_received_sats: u64,
484    pub unconfirmed_sats: u64,
485}