Skip to main content

csv_adapter_bitcoin/
mempool_rpc.rs

1//! Real Bitcoin Signet RPC via mempool.space public REST API
2//!
3//! This provides a production-ready RPC implementation that talks to
4//! the mempool.space Signet REST API — no local Bitcoin Core node needed.
5//!
6//! Includes automatic retry with exponential backoff for transient failures.
7//! Enable the `signet-rest` feature to use this implementation.
8
9use bitcoin::{OutPoint, Txid};
10use bitcoin_hashes::Hash as BitcoinHash;
11use reqwest::blocking::Client;
12use std::thread;
13use std::time::{Duration, Instant};
14
15use crate::proofs::extract_merkle_proof_from_block;
16use crate::rpc::BitcoinRpc;
17use crate::types::BitcoinInclusionProof;
18
19/// Base URL for mempool.space Signet API
20pub const MEMPOOL_SIGNET_BASE: &str = "https://mempool.space/signet/api";
21
22/// Maximum number of retries for transient failures
23const MAX_RETRIES: u32 = 3;
24/// Initial backoff duration before the first retry
25const INITIAL_BACKOFF: Duration = Duration::from_secs(2);
26
27/// Real Bitcoin Signet RPC client backed by mempool.space REST API
28pub struct MempoolSignetRpc {
29    client: Client,
30    base_url: String,
31}
32
33impl MempoolSignetRpc {
34    /// Create a new RPC client for Signet (default: mempool.space)
35    pub fn new() -> Self {
36        Self::with_url(MEMPOOL_SIGNET_BASE.to_string())
37    }
38
39    /// Create with a custom base URL (for self-hosted mempool instances)
40    pub fn with_url(base_url: String) -> Self {
41        let client = Client::builder()
42            .timeout(Duration::from_secs(30))
43            .build()
44            .expect("Failed to create HTTP client");
45        Self { client, base_url }
46    }
47
48    /// HTTP GET with automatic retry and exponential backoff
49    fn get_with_retry<T: serde::de::DeserializeOwned>(
50        &self,
51        url: &str,
52    ) -> Result<T, Box<dyn std::error::Error + Send + Sync>> {
53        let mut last_err = None;
54        let mut backoff = INITIAL_BACKOFF;
55
56        for attempt in 0..=MAX_RETRIES {
57            if attempt > 0 {
58                log::warn!(
59                    "Retry {}/{} for {} after {:?} backoff",
60                    attempt,
61                    MAX_RETRIES,
62                    url,
63                    backoff
64                );
65                thread::sleep(backoff);
66                backoff *= 2;
67            }
68
69            match self.client.get(url).send() {
70                Ok(resp) if resp.status().is_success() => {
71                    return resp.json::<T>().map_err(|e| e.into());
72                }
73                Ok(resp) => {
74                    last_err = Some(format!("HTTP {} at {}", resp.status(), url).into());
75                }
76                Err(e) => {
77                    last_err = Some(format!("Network error at {}: {}", url, e).into());
78                }
79            }
80        }
81        Err(last_err.unwrap_or_else(|| "Max retries exceeded".into()))
82    }
83
84    /// HTTP GET text with retry
85    fn get_text_with_retry(
86        &self,
87        url: &str,
88    ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
89        let mut last_err = None;
90        let mut backoff = INITIAL_BACKOFF;
91
92        for attempt in 0..=MAX_RETRIES {
93            if attempt > 0 {
94                thread::sleep(backoff);
95                backoff *= 2;
96            }
97
98            match self.client.get(url).send() {
99                Ok(resp) if resp.status().is_success() => {
100                    return resp.text().map_err(|e| e.into());
101                }
102                Ok(resp) => {
103                    last_err = Some(format!("HTTP {} at {}", resp.status(), url).into());
104                }
105                Err(e) => {
106                    last_err = Some(format!("Network error at {}: {}", url, e).into());
107                }
108            }
109        }
110        Err(last_err.unwrap_or_else(|| "Max retries exceeded".into()))
111    }
112
113    /// HTTP POST text with retry
114    fn post_text_with_retry(
115        &self,
116        url: &str,
117        body: String,
118    ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
119        let mut last_err = None;
120        let mut backoff = INITIAL_BACKOFF;
121
122        for attempt in 0..=MAX_RETRIES {
123            if attempt > 0 {
124                thread::sleep(backoff);
125                backoff *= 2;
126            }
127
128            match self
129                .client
130                .post(url)
131                .header("Content-Type", "text/plain")
132                .body(body.clone())
133                .send()
134            {
135                Ok(resp) if resp.status().is_success() => {
136                    return resp.text().map_err(|e| e.into());
137                }
138                Ok(resp) => {
139                    let status = resp.status();
140                    let error_text = resp.text().unwrap_or_default();
141                    last_err = Some(format!("HTTP {} at {}: {}", status, url, error_text).into());
142                }
143                Err(e) => {
144                    last_err = Some(format!("Network error at {}: {}", url, e).into());
145                }
146            }
147        }
148        Err(last_err.unwrap_or_else(|| "Max retries exceeded".into()))
149    }
150
151    /// Get block info (height, tx count, etc.)
152    pub fn get_block_info(
153        &self,
154        block_hash: &str,
155    ) -> Result<BlockInfo, Box<dyn std::error::Error + Send + Sync>> {
156        let url = format!("{}/block/{}", self.base_url, block_hash);
157        self.get_with_retry(&url)
158    }
159
160    /// Get transaction status (confirmed/unconfirmed, block height, hash)
161    pub fn get_tx_status(
162        &self,
163        txid: &str,
164    ) -> Result<TxStatus, Box<dyn std::error::Error + Send + Sync>> {
165        let url = format!("{}/tx/{}/status", self.base_url, txid);
166        self.get_with_retry(&url)
167    }
168
169    /// Get full transaction details (inputs, outputs, fee, etc.)
170    pub fn get_tx(&self, txid: &str) -> Result<TxDetail, Box<dyn std::error::Error + Send + Sync>> {
171        let url = format!("{}/tx/{}", self.base_url, txid);
172        self.get_with_retry(&url)
173    }
174
175    /// Get raw transaction hex
176    pub fn get_tx_hex(
177        &self,
178        txid: &str,
179    ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
180        let url = format!("{}/tx/{}/hex", self.base_url, txid);
181        self.get_text_with_retry(&url)
182    }
183
184    /// Get block txids for Merkle proof extraction
185    pub fn get_block_txids(
186        &self,
187        block_hash: &str,
188    ) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
189        let url = format!("{}/block/{}/txids", self.base_url, block_hash);
190        self.get_with_retry(&url)
191    }
192
193    /// Wait for transaction to reach required confirmations
194    pub fn wait_for_confirmation(
195        &self,
196        txid: [u8; 32],
197        required_confirmations: u64,
198        timeout_secs: u64,
199    ) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
200        let txid_hex = hex::encode(txid);
201        let start = Instant::now();
202        let poll_interval = Duration::from_secs(10);
203
204        loop {
205            if start.elapsed() > Duration::from_secs(timeout_secs) {
206                return Err("Timeout waiting for confirmation".into());
207            }
208
209            match self.get_tx_status(&txid_hex) {
210                Ok(status) => {
211                    if status.confirmed {
212                        let tx_height = status.block_height.unwrap_or(0) as u64;
213                        let new_height = self.get_block_count()?;
214                        let confirmations = new_height.saturating_sub(tx_height) + 1;
215
216                        if confirmations >= required_confirmations {
217                            return Ok(confirmations);
218                        }
219
220                        log::info!(
221                            "Tx {} has {} confirmations, waiting for {}...",
222                            &txid_hex[..16],
223                            confirmations,
224                            required_confirmations
225                        );
226                    }
227                }
228                Err(e) => {
229                    log::debug!("Tx {} not found yet: {}", &txid_hex[..16], e);
230                }
231            }
232
233            thread::sleep(poll_interval);
234        }
235    }
236
237    /// Extract Merkle proof for a transaction from its containing block
238    pub fn extract_merkle_proof(
239        &self,
240        txid: [u8; 32],
241        block_hash: [u8; 32],
242    ) -> Result<BitcoinInclusionProof, Box<dyn std::error::Error + Send + Sync>> {
243        let block_hash_hex = hex::encode(block_hash);
244
245        let all_txids_hex = self.get_block_txids(&block_hash_hex)?;
246        let all_txids: Vec<[u8; 32]> = all_txids_hex
247            .iter()
248            .map(|t| {
249                let decoded = hex::decode(t)?;
250                let mut arr = [0u8; 32];
251                arr.copy_from_slice(&decoded);
252                Ok(arr)
253            })
254            .collect::<Result<Vec<_>, Box<dyn std::error::Error + Send + Sync>>>()?;
255
256        let block_info = self.get_block_info(&block_hash_hex)?;
257        let block_height = block_info.height;
258
259        extract_merkle_proof_from_block(txid, &all_txids, block_hash, block_height as u64)
260            .ok_or_else(|| "Failed to extract Merkle proof for txid".into())
261    }
262}
263
264impl Default for MempoolSignetRpc {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270impl BitcoinRpc for MempoolSignetRpc {
271    fn get_block_count(&self) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
272        let url = format!("{}/blocks/tip/height", self.base_url);
273        self.get_with_retry(&url)
274    }
275
276    fn get_block_hash(
277        &self,
278        height: u64,
279    ) -> Result<[u8; 32], Box<dyn std::error::Error + Send + Sync>> {
280        let url = format!("{}/block-height/{}", self.base_url, height);
281        let hash_hex: String = self.get_text_with_retry(&url)?;
282        let hash_bytes = hex::decode(hash_hex.trim())?;
283        let mut result = [0u8; 32];
284        result.copy_from_slice(&hash_bytes);
285        Ok(result)
286    }
287
288    fn is_utxo_unspent(
289        &self,
290        txid: [u8; 32],
291        vout: u32,
292    ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
293        let txid_hex = hex::encode(txid);
294        let spend_url = format!("{}/tx/{}/outspend/{}", self.base_url, txid_hex, vout);
295        let spend_status: OutSpendStatus = self.get_with_retry(&spend_url)?;
296        Ok(!spend_status.spent)
297    }
298
299    fn send_raw_transaction(
300        &self,
301        tx_bytes: Vec<u8>,
302    ) -> Result<[u8; 32], Box<dyn std::error::Error + Send + Sync>> {
303        let url = format!("{}/tx", self.base_url);
304        let tx_hex = hex::encode(&tx_bytes);
305
306        let txid_hex = self.post_text_with_retry(&url, tx_hex)?;
307        let txid_bytes = hex::decode(txid_hex.trim())?;
308        let mut result = [0u8; 32];
309        result.copy_from_slice(&txid_bytes);
310        Ok(result)
311    }
312
313    fn get_tx_confirmations(
314        &self,
315        txid: [u8; 32],
316    ) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
317        let txid_hex = hex::encode(txid);
318
319        match self.get_tx_status(&txid_hex) {
320            Ok(status) => {
321                if status.confirmed {
322                    let current_height = self.get_block_count()?;
323                    let tx_height = status.block_height.unwrap_or(0) as u64;
324                    Ok(current_height.saturating_sub(tx_height) + 1)
325                } else {
326                    Ok(0)
327                }
328            }
329            Err(_) => Ok(0),
330        }
331    }
332}
333
334/// Block info response from mempool.space
335#[derive(Debug, Clone, serde::Deserialize)]
336pub struct BlockInfo {
337    pub id: String,
338    pub height: u32,
339    pub version: u32,
340    pub timestamp: u64,
341    pub tx_count: u32,
342    pub size: u64,
343    pub weight: u64,
344    pub merkle_root: String,
345}
346
347/// Transaction status response
348#[derive(Debug, Clone, serde::Deserialize)]
349pub struct TxStatus {
350    pub confirmed: bool,
351    #[serde(default)]
352    pub block_height: Option<u32>,
353    #[serde(default)]
354    pub block_hash: Option<String>,
355    #[serde(default)]
356    pub block_time: Option<u64>,
357}
358
359/// Transaction detail response
360#[derive(Debug, Clone, serde::Deserialize)]
361pub struct TxDetail {
362    pub txid: String,
363    pub version: u32,
364    pub locktime: u64,
365    pub vin: Vec<TxInput>,
366    pub vout: Vec<TxOutput>,
367    pub size: u64,
368    pub weight: u64,
369    pub fee: u64,
370}
371
372#[derive(Debug, Clone, serde::Deserialize)]
373pub struct TxInput {
374    pub txid: String,
375    pub vout: u32,
376    pub prevout: Option<TxPrevout>,
377    pub scriptsig: String,
378    pub is_coinbase: bool,
379}
380
381#[derive(Debug, Clone, serde::Deserialize)]
382pub struct TxOutput {
383    pub scriptpubkey: String,
384    pub scriptpubkey_asm: String,
385    pub scriptpubkey_type: String,
386    pub scriptpubkey_address: String,
387    pub value: u64,
388}
389
390#[derive(Debug, Clone, serde::Deserialize)]
391pub struct TxPrevout {
392    pub scriptpubkey: String,
393    pub scriptpubkey_asm: String,
394    pub scriptpubkey_type: String,
395    pub scriptpubkey_address: String,
396    pub value: u64,
397}
398
399/// Output spend status
400#[derive(Debug, Clone, serde::Deserialize)]
401pub struct OutSpendStatus {
402    pub spent: bool,
403    #[serde(default)]
404    pub txid: Option<String>,
405    #[serde(default)]
406    pub vin: Option<u32>,
407    #[serde(default)]
408    pub status: Option<TxStatus>,
409}
410
411/// Get UTXOs for a specific address
412pub fn get_address_utxos(
413    rpc: &MempoolSignetRpc,
414    address: &bitcoin::Address,
415) -> Result<Vec<(OutPoint, u64)>, Box<dyn std::error::Error + Send + Sync>> {
416    let url = format!("{}/address/{}/utxo", rpc.base_url, address);
417    let utxos: Vec<AddressUtxo> = rpc.get_with_retry(&url)?;
418
419    let result: Vec<(OutPoint, u64)> = utxos
420        .into_iter()
421        .map(|u| {
422            let mut txid_bytes = hex::decode(&u.txid)?;
423            // mempool.space returns txid in display order (big-endian)
424            // Bitcoin internally uses little-endian (hash byte order)
425            txid_bytes.reverse();
426            let txid = Txid::from_slice(&txid_bytes).expect("valid txid");
427            let outpoint = OutPoint::new(txid, u.vout);
428            Ok((outpoint, u.value))
429        })
430        .collect::<Result<Vec<_>, Box<dyn std::error::Error + Send + Sync>>>()?;
431
432    Ok(result)
433}
434
435/// Address UTXO response
436#[derive(Debug, Clone, serde::Deserialize)]
437pub struct AddressUtxo {
438    pub txid: String,
439    pub vout: u32,
440    pub value: u64,
441    pub status: TxStatus,
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    #[ignore = "requires network"]
450    fn test_get_block_count() {
451        let rpc = MempoolSignetRpc::new();
452        let height = rpc.get_block_count().unwrap();
453        assert!(height > 200_000, "Signet height should be > 200k");
454        println!("Current Signet height: {}", height);
455    }
456
457    #[test]
458    #[ignore = "requires network"]
459    fn test_get_block_hash() {
460        let rpc = MempoolSignetRpc::new();
461        let height = rpc.get_block_count().unwrap();
462        let hash = rpc.get_block_hash(height).unwrap();
463        assert_ne!(hash, [0u8; 32]);
464        println!("Block hash at {}: {}", height, hex::encode(hash));
465    }
466}