boltz_client/network/
esplora.rs

1use crate::error::Error;
2use crate::network::{BitcoinChain, BitcoinClient, LiquidChain, LiquidClient};
3use bitcoin::ScriptBuf;
4use elements::hex::ToHex;
5use elements::pset::serialize::Serialize;
6use reqwest::Response;
7use serde::Deserialize;
8use std::str::FromStr;
9use std::time::Duration;
10
11pub const DEFAULT_MAINNET_NODE: &str = "https://blockstream.info/api";
12pub const DEFAULT_TESTNET_NODE: &str = "https://blockstream.info/testnet/api";
13pub const DEFAULT_REGTEST_NODE: &str = "http://localhost:4002/api";
14pub const DEFAULT_LIQUID_MAINNET_NODE: &str = "https://blockstream.info/liquid/api";
15pub const DEFAULT_LIQUID_TESTNET_NODE: &str = "https://blockstream.info/liquidtestnet/api";
16pub const DEFAULT_LIQUID_REGTEST_NODE: &str = "http://localhost:4003/api";
17
18pub const DEFAULT_ESPLORA_TIMEOUT_SECS: u64 = 30;
19
20pub struct EsploraBitcoinClient {
21    client: reqwest::Client,
22    base_url: String,
23    timeout: Duration,
24    network: BitcoinChain,
25}
26
27impl EsploraBitcoinClient {
28    pub fn new(network: BitcoinChain, url: &str, timeout: u64) -> Self {
29        Self::with_client(reqwest::Client::new(), network, url, timeout)
30    }
31
32    pub fn with_client(
33        client: reqwest::Client,
34        network: BitcoinChain,
35        url: &str,
36        timeout: u64,
37    ) -> Self {
38        Self {
39            client,
40            base_url: url.to_string(),
41            timeout: Duration::from_secs(timeout),
42            network,
43        }
44    }
45
46    pub fn default(network: BitcoinChain, regtest_url: Option<&str>) -> Self {
47        match network {
48            BitcoinChain::Bitcoin => {
49                Self::new(network, DEFAULT_MAINNET_NODE, DEFAULT_ESPLORA_TIMEOUT_SECS)
50            }
51            BitcoinChain::BitcoinTestnet => {
52                Self::new(network, DEFAULT_TESTNET_NODE, DEFAULT_ESPLORA_TIMEOUT_SECS)
53            }
54            BitcoinChain::BitcoinRegtest => Self::new(
55                network,
56                regtest_url.unwrap_or(DEFAULT_REGTEST_NODE),
57                DEFAULT_ESPLORA_TIMEOUT_SECS,
58            ),
59        }
60    }
61
62    fn extract_address_utxos(
63        txs: &[Transaction],
64        address: &str,
65    ) -> Result<Vec<(bitcoin::OutPoint, bitcoin::TxOut)>, Error> {
66        let mut result = Vec::new();
67
68        for tx in txs {
69            for (vout, output) in tx.vout.iter().enumerate() {
70                // Check if this output belongs to our address
71                if output.scriptpubkey_address != address {
72                    continue;
73                }
74
75                // Check if this output is spent by any confirmed transaction
76                let is_spent = txs.iter().any(|spending_tx| {
77                    let spends_our_output = spending_tx
78                        .vin
79                        .iter()
80                        .any(|input| input.txid == tx.txid && input.vout == vout as u32);
81
82                    spends_our_output && spending_tx.status.confirmed
83                });
84
85                if is_spent {
86                    continue;
87                }
88
89                let txid = match bitcoin::Txid::from_str(&tx.txid) {
90                    Ok(txid) => txid,
91                    Err(e) => {
92                        return Err(Error::Esplora(format!(
93                            "Failed to parse txid {}: {e}",
94                            tx.txid
95                        )))
96                    }
97                };
98                let script_pubkey = match ScriptBuf::from_hex(&output.scriptpubkey) {
99                    Ok(script) => script,
100                    Err(e) => {
101                        return Err(Error::Esplora(format!(
102                            "Failed to parse script pubkey {}: {e}",
103                            output.scriptpubkey
104                        )))
105                    }
106                };
107                let out_point = bitcoin::OutPoint::new(txid, vout as u32);
108                let tx_out = bitcoin::TxOut {
109                    value: bitcoin::Amount::from_sat(output.value),
110                    script_pubkey,
111                };
112
113                result.push((out_point, tx_out));
114            }
115        }
116
117        Ok(result)
118    }
119}
120
121#[macros::async_trait]
122impl BitcoinClient for EsploraBitcoinClient {
123    async fn get_address_balance(&self, address: &bitcoin::Address) -> Result<(u64, i64), Error> {
124        let url = format!("{}/address/{}", self.base_url, address);
125        let response = get_with_retry(&self.client, &url, self.timeout).await?;
126        let address_info: AddressInfo = serde_json::from_str(&response.text().await?)?;
127
128        let confirmed_balance = address_info
129            .chain_stats
130            .funded_txo_sum
131            .checked_sub(address_info.chain_stats.spent_txo_sum)
132            .ok_or(Error::Generic(format!(
133                "Confirmed spent {} > Confirmed funded {}",
134                address_info.chain_stats.spent_txo_sum, address_info.chain_stats.funded_txo_sum
135            )))?;
136        let unconfirmed_balance = address_info.mempool_stats.funded_txo_sum as i64
137            - address_info.mempool_stats.spent_txo_sum as i64;
138
139        Ok((confirmed_balance, unconfirmed_balance))
140    }
141
142    async fn get_address_utxos(
143        &self,
144        address: &bitcoin::Address,
145    ) -> Result<Vec<(bitcoin::OutPoint, bitcoin::TxOut)>, Error> {
146        let url = format!("{}/address/{}/txs", self.base_url, address);
147        let response = get_with_retry(&self.client, &url, self.timeout).await?;
148
149        let txs: Vec<Transaction> = serde_json::from_str(&response.text().await?)?;
150
151        Self::extract_address_utxos(&txs, &address.to_string())
152    }
153
154    async fn broadcast_tx(&self, signed_tx: &bitcoin::Transaction) -> Result<bitcoin::Txid, Error> {
155        let tx_hex = signed_tx.serialize().to_hex();
156        let response = self
157            .client
158            .post(format!("{}/tx", self.base_url))
159            .timeout(self.timeout)
160            .body(tx_hex)
161            .send()
162            .await
163            .map_err(|e| Error::Esplora(e.to_string()))?;
164        let raw_text = response.text().await?;
165        let txid = bitcoin::Txid::from_str(&raw_text)
166            .map_err(|e| Error::Esplora(format!("Failed to parse txid {raw_text}: {e}")))?;
167        Ok(txid)
168    }
169    fn network(&self) -> BitcoinChain {
170        self.network
171    }
172}
173
174pub struct EsploraLiquidClient {
175    client: reqwest::Client,
176    base_url: String,
177    timeout: Duration,
178    network: LiquidChain,
179}
180
181impl EsploraLiquidClient {
182    pub fn new(network: LiquidChain, url: &str, timeout: u64) -> Self {
183        Self::with_client(reqwest::Client::new(), network, url, timeout)
184    }
185
186    pub fn with_client(
187        client: reqwest::Client,
188        network: LiquidChain,
189        url: &str,
190        timeout: u64,
191    ) -> Self {
192        Self {
193            client,
194            base_url: url.to_string(),
195            timeout: Duration::from_secs(timeout),
196            network,
197        }
198    }
199
200    pub fn default(network: LiquidChain, regtest_url: Option<&str>) -> Self {
201        match network {
202            LiquidChain::Liquid => Self::new(
203                network,
204                DEFAULT_LIQUID_MAINNET_NODE,
205                DEFAULT_ESPLORA_TIMEOUT_SECS,
206            ),
207            LiquidChain::LiquidTestnet => Self::new(
208                network,
209                DEFAULT_LIQUID_TESTNET_NODE,
210                DEFAULT_ESPLORA_TIMEOUT_SECS,
211            ),
212            LiquidChain::LiquidRegtest => Self::new(
213                network,
214                regtest_url.unwrap_or(DEFAULT_LIQUID_REGTEST_NODE),
215                DEFAULT_ESPLORA_TIMEOUT_SECS,
216            ),
217        }
218    }
219}
220
221#[macros::async_trait]
222impl LiquidClient for EsploraLiquidClient {
223    async fn get_address_utxo(
224        &self,
225        address: &elements::Address,
226    ) -> Result<Option<(elements::OutPoint, elements::TxOut)>, Error> {
227        let utxos_url = format!("{}/address/{}/utxo", self.base_url, address);
228        let utxos_response = get_with_retry(&self.client, &utxos_url, self.timeout).await?;
229        let utxos: Vec<Utxo> = serde_json::from_str(&utxos_response.text().await?)?;
230
231        let txid = &utxos
232            .last()
233            .ok_or(Error::Protocol(
234                "Esplora could not find a Liquid UTXO for script".to_string(),
235            ))?
236            .txid;
237
238        let raw_tx_url = format!("{}/tx/{}/raw", self.base_url, txid);
239        let raw_tx_response = get_with_retry(&self.client, &raw_tx_url, self.timeout).await?;
240        let raw_tx = raw_tx_response.bytes().await?;
241        let tx: elements::Transaction = elements::encode::deserialize(&raw_tx)?;
242        for (vout, output) in tx.clone().output.into_iter().enumerate() {
243            if output.script_pubkey == address.script_pubkey() {
244                let outpoint_0 = elements::OutPoint::new(tx.txid(), vout as u32);
245
246                return Ok(Some((outpoint_0, output)));
247            }
248        }
249        Ok(None)
250    }
251
252    async fn get_genesis_hash(&self) -> Result<elements::BlockHash, Error> {
253        let url = format!("{}/block-height/0", self.base_url);
254        let response = get_with_retry(&self.client, &url, self.timeout).await?;
255        let text = response.text().await?;
256        Ok(elements::BlockHash::from_str(&text)?)
257    }
258
259    async fn broadcast_tx(&self, signed_tx: &elements::Transaction) -> Result<String, Error> {
260        let url = format!("{}/tx", self.base_url);
261        let tx_hex = signed_tx.serialize().to_hex();
262        let response = self
263            .client
264            .post(url)
265            .timeout(self.timeout)
266            .body(tx_hex)
267            .send()
268            .await
269            .map_err(|e| Error::Esplora(e.to_string()))?;
270        Ok(response.text().await?)
271    }
272    fn network(&self) -> LiquidChain {
273        self.network
274    }
275}
276
277async fn get_with_retry(
278    client: &reqwest::Client,
279    url: &str,
280    timeout: Duration,
281) -> Result<Response, Error> {
282    let mut attempt = 0;
283    loop {
284        let response = client
285            .get(url)
286            .timeout(timeout)
287            .send()
288            .await
289            .map_err(|e| Error::Esplora(e.to_string()))?;
290
291        let level = if response.status() == 200 {
292            log::Level::Trace
293        } else {
294            log::Level::Info
295        };
296        log::log!(
297            level,
298            "{} status_code:{} - body bytes:{:?}",
299            &url,
300            response.status(),
301            response.content_length(),
302        );
303
304        // 429 Too many requests
305        // 503 Service Temporarily Unavailable
306        if response.status() == 429 || response.status() == 503 {
307            if attempt > 6 {
308                log::warn!("{url} tried 6 times, failing");
309                return Err(Error::Esplora("Too many retries".to_string()));
310            }
311            let secs = 1 << attempt;
312
313            log::debug!("{url} waiting {secs}");
314
315            async_sleep(secs * 1000).await;
316            attempt += 1;
317        } else {
318            return Ok(response);
319        }
320    }
321}
322
323// based on https://users.rust-lang.org/t/rust-wasm-async-sleeping-for-100-milli-seconds-goes-up-to-1-minute/81177
324// TODO remove/handle/justify unwraps
325#[cfg(all(target_family = "wasm", target_os = "unknown"))]
326pub async fn async_sleep(millis: i32) {
327    let mut cb = |resolve: js_sys::Function, _reject: js_sys::Function| {
328        web_sys::window()
329            .unwrap()
330            .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, millis)
331            .unwrap();
332    };
333    let p = js_sys::Promise::new(&mut cb);
334    wasm_bindgen_futures::JsFuture::from(p).await.unwrap();
335}
336#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
337pub async fn async_sleep(millis: i32) {
338    tokio::time::sleep(tokio::time::Duration::from_millis(millis as u64)).await;
339}
340
341#[derive(Debug, Deserialize)]
342struct AddressInfo {
343    chain_stats: Stats,
344    mempool_stats: Stats,
345}
346
347#[derive(Debug, Deserialize)]
348struct Stats {
349    funded_txo_sum: u64,
350    spent_txo_sum: u64,
351}
352
353#[derive(Debug, Deserialize, Clone)]
354pub struct Transaction {
355    pub txid: String,
356    pub vin: Vec<Input>,
357    pub vout: Vec<Output>,
358    pub status: Status,
359}
360
361#[derive(Debug, Deserialize, Clone)]
362pub struct Input {
363    pub txid: String,
364    pub vout: u32,
365}
366
367#[derive(Debug, Deserialize, Clone)]
368pub struct Output {
369    pub scriptpubkey: String,
370    pub scriptpubkey_address: String,
371    pub value: u64,
372}
373
374#[derive(Debug, Deserialize, Clone)]
375pub struct Status {
376    pub confirmed: bool,
377}
378
379#[derive(Debug, Deserialize, Clone)]
380pub struct Utxo {
381    pub txid: String,
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use elements::hex::ToHex;
388    use std::str::FromStr;
389
390    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
391    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
392
393    #[macros::async_test_all]
394    async fn test_esplora_default_clients() {
395        let esplora_client = EsploraBitcoinClient::default(BitcoinChain::Bitcoin, None);
396        assert!(esplora_client
397            .get_address_balance(
398                &bitcoin::Address::from_str("bc1qlaghkgntxw84d8jfv45deup7v32dfmncs7t3ct")
399                    .unwrap()
400                    .assume_checked()
401            )
402            .await
403            .is_ok());
404
405        let esplora_client = EsploraLiquidClient::default(LiquidChain::Liquid, None);
406        assert_eq!(
407            esplora_client.get_genesis_hash().await.unwrap().to_hex(),
408            "1466275836220db2944ca059a3a10ef6fd2ea684b0688d2c379296888a206003"
409        );
410    }
411
412    #[macros::test_all]
413    fn test_extract_address_utxos() {
414        let our_script_hex = "aaaa";
415        let other_script_hex = "bbbb";
416        let our_address = "test_address";
417        let other_address = "other_address";
418
419        let txid_1 = "1111111111111111111111111111111111111111111111111111111111111111";
420        let txid_2 = "2222222222222222222222222222222222222222222222222222222222222222";
421        let txid_3 = "3333333333333333333333333333333333333333333333333333333333333333";
422        let txid_4 = "4444444444444444444444444444444444444444444444444444444444444444";
423        let txid_confirmed_spend =
424            "5555555555555555555555555555555555555555555555555555555555555555";
425        let txid_unconfirmed_spend =
426            "6666666666666666666666666666666666666666666666666666666666666666";
427
428        // Pending tx with unspent output
429        let tx1 = Transaction {
430            txid: txid_1.to_string(),
431            vin: vec![Input {
432                txid: "1".to_string(),
433                vout: 0,
434            }],
435            vout: vec![Output {
436                scriptpubkey: our_script_hex.to_string(),
437                scriptpubkey_address: our_address.to_string(),
438                value: 1000,
439            }],
440            status: Status { confirmed: false },
441        };
442
443        // Confirmed tx with unspent output
444        let tx2 = Transaction {
445            txid: txid_2.to_string(),
446            vin: vec![Input {
447                txid: "2".to_string(),
448                vout: 0,
449            }],
450            vout: vec![Output {
451                scriptpubkey: our_script_hex.to_string(),
452                scriptpubkey_address: our_address.to_string(),
453                value: 2000,
454            }],
455            status: Status { confirmed: true },
456        };
457
458        // Confirmed tx with unconfirmed spend
459        let tx3 = Transaction {
460            txid: txid_3.to_string(),
461            vin: vec![Input {
462                txid: "3".to_string(),
463                vout: 0,
464            }],
465            vout: vec![Output {
466                scriptpubkey: our_script_hex.to_string(),
467                scriptpubkey_address: our_address.to_string(),
468                value: 5000,
469            }],
470            status: Status { confirmed: true },
471        };
472
473        // Confirmed tx with confirmed spend
474        let tx4 = Transaction {
475            txid: txid_4.to_string(),
476            vin: vec![Input {
477                txid: "4".to_string(),
478                vout: 0,
479            }],
480            vout: vec![Output {
481                scriptpubkey: our_script_hex.to_string(),
482                scriptpubkey_address: our_address.to_string(),
483                value: 4500,
484            }],
485            status: Status { confirmed: true },
486        };
487
488        // Confirmed spending tx for tx4's output
489        let spending_tx = Transaction {
490            txid: txid_confirmed_spend.to_string(),
491            vin: vec![Input {
492                txid: txid_4.to_string(),
493                vout: 0,
494            }],
495            vout: vec![Output {
496                scriptpubkey: other_script_hex.to_string(),
497                scriptpubkey_address: other_address.to_string(),
498                value: 4000,
499            }],
500            status: Status { confirmed: true },
501        };
502
503        // Pending spending tx for tx3's output
504        let pending_spending_tx = Transaction {
505            txid: txid_unconfirmed_spend.to_string(),
506            vin: vec![Input {
507                txid: txid_3.to_string(),
508                vout: 0,
509            }],
510            vout: vec![Output {
511                scriptpubkey: other_script_hex.to_string(),
512                scriptpubkey_address: other_address.to_string(),
513                value: 4950,
514            }],
515            status: Status { confirmed: false },
516        };
517
518        // Call the updated method
519        let utxo_pairs = EsploraBitcoinClient::extract_address_utxos(
520            &[
521                tx1.clone(),
522                tx2.clone(),
523                tx3.clone(),
524                tx4.clone(),
525                spending_tx.clone(),
526                pending_spending_tx.clone(),
527            ],
528            our_address,
529        )
530        .unwrap();
531
532        assert_eq!(utxo_pairs.len(), 3);
533
534        // Pending tx with unspent output
535        assert!(utxo_pairs
536            .iter()
537            .any(|(outpoint, _)| outpoint.txid.to_string() == tx1.txid));
538
539        // Confirmed tx with unspent output
540        assert!(utxo_pairs
541            .iter()
542            .any(|(outpoint, _)| outpoint.txid.to_string() == tx2.txid));
543
544        // Confirmed tx with unconfirmed spend
545        assert!(utxo_pairs
546            .iter()
547            .any(|(outpoint, _)| outpoint.txid.to_string() == tx3.txid));
548
549        // Make sure tx4 is NOT in the result (because it's spent by a confirmed tx)
550        assert!(!utxo_pairs
551            .iter()
552            .any(|(outpoint, _)| outpoint.txid.to_string() == tx4.txid));
553    }
554}