rustywallet_electrum/
client.rs

1//! Electrum client for blockchain queries.
2//!
3//! This module provides the main `ElectrumClient` for interacting with
4//! Electrum servers to query balances, UTXOs, and transactions.
5
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use serde_json::json;
9
10use crate::error::{ElectrumError, Result};
11use crate::scripthash::address_to_scripthash;
12use crate::transport::Transport;
13use crate::types::{Balance, ClientConfig, ServerVersion, TxHistory, Utxo};
14
15/// Electrum protocol client.
16///
17/// Provides async methods for querying Bitcoin blockchain data via Electrum protocol.
18///
19/// # Example
20/// ```no_run
21/// use rustywallet_electrum::{ElectrumClient, ClientConfig};
22///
23/// #[tokio::main]
24/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
25///     // Connect to server
26///     let client = ElectrumClient::new("electrum.blockstream.info").await?;
27///     
28///     // Check balance
29///     let balance = client.get_balance("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa").await?;
30///     println!("Balance: {} satoshis", balance.confirmed);
31///     
32///     Ok(())
33/// }
34/// ```
35pub struct ElectrumClient {
36    transport: Transport,
37    request_id: AtomicU64,
38}
39
40impl ElectrumClient {
41    /// Create a new client with SSL connection to the specified server.
42    ///
43    /// Uses default port 50002 for SSL.
44    pub async fn new(server: &str) -> Result<Self> {
45        Self::with_config(ClientConfig::ssl(server)).await
46    }
47
48    /// Create a new client with custom configuration.
49    pub async fn with_config(config: ClientConfig) -> Result<Self> {
50        let transport = Transport::connect(config).await?;
51        Ok(Self {
52            transport,
53            request_id: AtomicU64::new(1),
54        })
55    }
56
57    /// Get the next request ID.
58    fn next_id(&self) -> u64 {
59        self.request_id.fetch_add(1, Ordering::SeqCst)
60    }
61
62    // ========== Balance Methods ==========
63
64    /// Get balance for a single address.
65    ///
66    /// # Arguments
67    /// * `address` - Bitcoin address (P2PKH, P2SH, P2WPKH, P2WSH, P2TR)
68    ///
69    /// # Returns
70    /// * `Balance` with confirmed and unconfirmed amounts in satoshis
71    pub async fn get_balance(&self, address: &str) -> Result<Balance> {
72        let scripthash = address_to_scripthash(address)?;
73        self.get_balance_scripthash(&scripthash).await
74    }
75
76    /// Get balance using scripthash directly.
77    pub async fn get_balance_scripthash(&self, scripthash: &str) -> Result<Balance> {
78        let id = self.next_id();
79        let result = self
80            .transport
81            .request(
82                id,
83                "blockchain.scripthash.get_balance",
84                vec![json!(scripthash)],
85            )
86            .await?;
87
88        serde_json::from_value(result).map_err(|e| ElectrumError::InvalidResponse(e.to_string()))
89    }
90
91    /// Get balances for multiple addresses in a single batch request.
92    ///
93    /// This is much more efficient than calling `get_balance` multiple times.
94    ///
95    /// # Arguments
96    /// * `addresses` - Slice of Bitcoin addresses
97    ///
98    /// # Returns
99    /// * Vector of `Balance` in the same order as input addresses
100    pub async fn get_balances(&self, addresses: &[&str]) -> Result<Vec<Balance>> {
101        if addresses.is_empty() {
102            return Ok(vec![]);
103        }
104
105        // Convert addresses to scripthashes
106        let scripthashes: Vec<String> = addresses
107            .iter()
108            .map(|a| address_to_scripthash(a))
109            .collect::<Result<Vec<_>>>()?;
110
111        self.get_balances_scripthash(&scripthashes).await
112    }
113
114    /// Get balances using scripthashes directly (batch).
115    pub async fn get_balances_scripthash(&self, scripthashes: &[String]) -> Result<Vec<Balance>> {
116        if scripthashes.is_empty() {
117            return Ok(vec![]);
118        }
119
120        let requests: Vec<(u64, &str, Vec<serde_json::Value>)> = scripthashes
121            .iter()
122            .map(|sh| {
123                (
124                    self.next_id(),
125                    "blockchain.scripthash.get_balance",
126                    vec![json!(sh)],
127                )
128            })
129            .collect();
130
131        let results = self.transport.batch_request(requests).await?;
132
133        results
134            .into_iter()
135            .map(|r| {
136                serde_json::from_value(r)
137                    .map_err(|e| ElectrumError::InvalidResponse(e.to_string()))
138            })
139            .collect()
140    }
141
142    // ========== UTXO Methods ==========
143
144    /// List unspent outputs (UTXOs) for an address.
145    ///
146    /// # Arguments
147    /// * `address` - Bitcoin address
148    ///
149    /// # Returns
150    /// * Vector of `Utxo` for the address
151    pub async fn list_unspent(&self, address: &str) -> Result<Vec<Utxo>> {
152        let scripthash = address_to_scripthash(address)?;
153        self.list_unspent_scripthash(&scripthash).await
154    }
155
156    /// List unspent outputs using scripthash directly.
157    pub async fn list_unspent_scripthash(&self, scripthash: &str) -> Result<Vec<Utxo>> {
158        let id = self.next_id();
159        let result = self
160            .transport
161            .request(
162                id,
163                "blockchain.scripthash.listunspent",
164                vec![json!(scripthash)],
165            )
166            .await?;
167
168        serde_json::from_value(result).map_err(|e| ElectrumError::InvalidResponse(e.to_string()))
169    }
170
171    // ========== Transaction Methods ==========
172
173    /// Get raw transaction by txid.
174    ///
175    /// # Arguments
176    /// * `txid` - Transaction ID (hex)
177    ///
178    /// # Returns
179    /// * Raw transaction hex string
180    pub async fn get_transaction(&self, txid: &str) -> Result<String> {
181        let id = self.next_id();
182        let result = self
183            .transport
184            .request(id, "blockchain.transaction.get", vec![json!(txid)])
185            .await?;
186
187        result
188            .as_str()
189            .map(|s| s.to_string())
190            .ok_or_else(|| ElectrumError::InvalidResponse("Expected string".into()))
191    }
192
193    /// Broadcast a signed transaction.
194    ///
195    /// # Arguments
196    /// * `raw_tx` - Signed transaction in hex format
197    ///
198    /// # Returns
199    /// * Transaction ID if broadcast successful
200    pub async fn broadcast(&self, raw_tx: &str) -> Result<String> {
201        let id = self.next_id();
202        let result = self
203            .transport
204            .request(id, "blockchain.transaction.broadcast", vec![json!(raw_tx)])
205            .await?;
206
207        result
208            .as_str()
209            .map(|s| s.to_string())
210            .ok_or_else(|| ElectrumError::InvalidResponse("Expected txid string".into()))
211    }
212
213    /// Get transaction history for an address.
214    ///
215    /// # Arguments
216    /// * `address` - Bitcoin address
217    ///
218    /// # Returns
219    /// * Vector of `TxHistory` entries
220    pub async fn get_history(&self, address: &str) -> Result<Vec<TxHistory>> {
221        let scripthash = address_to_scripthash(address)?;
222        self.get_history_scripthash(&scripthash).await
223    }
224
225    /// Get transaction history using scripthash directly.
226    pub async fn get_history_scripthash(&self, scripthash: &str) -> Result<Vec<TxHistory>> {
227        let id = self.next_id();
228        let result = self
229            .transport
230            .request(
231                id,
232                "blockchain.scripthash.get_history",
233                vec![json!(scripthash)],
234            )
235            .await?;
236
237        serde_json::from_value(result).map_err(|e| ElectrumError::InvalidResponse(e.to_string()))
238    }
239
240    // ========== Server Methods ==========
241
242    /// Get server version information.
243    ///
244    /// Also performs protocol version negotiation.
245    pub async fn server_version(&self) -> Result<ServerVersion> {
246        let id = self.next_id();
247        let result = self
248            .transport
249            .request(
250                id,
251                "server.version",
252                vec![json!("rustywallet-electrum"), json!("1.4")],
253            )
254            .await?;
255
256        let arr = result
257            .as_array()
258            .ok_or_else(|| ElectrumError::InvalidResponse("Expected array".into()))?;
259
260        if arr.len() < 2 {
261            return Err(ElectrumError::InvalidResponse(
262                "Expected [server, protocol]".into(),
263            ));
264        }
265
266        Ok(ServerVersion {
267            server_software: arr[0].as_str().unwrap_or("unknown").to_string(),
268            protocol_version: arr[1].as_str().unwrap_or("unknown").to_string(),
269        })
270    }
271
272    /// Ping the server to check connection.
273    pub async fn ping(&self) -> Result<()> {
274        let id = self.next_id();
275        self.transport
276            .request(id, "server.ping", vec![])
277            .await?;
278        Ok(())
279    }
280
281    /// Get the current block height.
282    pub async fn get_block_height(&self) -> Result<u64> {
283        let id = self.next_id();
284        let result = self
285            .transport
286            .request(id, "blockchain.headers.subscribe", vec![])
287            .await?;
288
289        result
290            .get("height")
291            .and_then(|h| h.as_u64())
292            .ok_or_else(|| ElectrumError::InvalidResponse("Expected height".into()))
293    }
294
295    /// Get estimated fee rate (satoshis per kilobyte).
296    ///
297    /// # Arguments
298    /// * `blocks` - Target confirmation blocks (e.g., 1, 6, 144)
299    pub async fn estimate_fee(&self, blocks: u32) -> Result<f64> {
300        let id = self.next_id();
301        let result = self
302            .transport
303            .request(id, "blockchain.estimatefee", vec![json!(blocks)])
304            .await?;
305
306        result
307            .as_f64()
308            .ok_or_else(|| ElectrumError::InvalidResponse("Expected fee rate".into()))
309    }
310}