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 if output.scriptpubkey_address != address {
72 continue;
73 }
74
75 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 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#[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 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 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 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 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 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 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 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 assert!(utxo_pairs
536 .iter()
537 .any(|(outpoint, _)| outpoint.txid.to_string() == tx1.txid));
538
539 assert!(utxo_pairs
541 .iter()
542 .any(|(outpoint, _)| outpoint.txid.to_string() == tx2.txid));
543
544 assert!(utxo_pairs
546 .iter()
547 .any(|(outpoint, _)| outpoint.txid.to_string() == tx3.txid));
548
549 assert!(!utxo_pairs
551 .iter()
552 .any(|(outpoint, _)| outpoint.txid.to_string() == tx4.txid));
553 }
554}