breez_sdk_liquid/chain/bitcoin/
esplora.rs

1use std::{collections::HashMap, sync::OnceLock, time::Duration};
2
3use esplora_client::{AsyncClient, Builder};
4use tokio::sync::Mutex;
5use tokio_with_wasm::alias as tokio;
6
7use crate::{
8    bitcoin::{
9        consensus::deserialize,
10        hashes::{sha256, Hash},
11        Address, OutPoint, Script, ScriptBuf, Transaction, Txid,
12    },
13    model::{BlockchainExplorer, Config},
14};
15
16use anyhow::{anyhow, Context, Result};
17
18use crate::model::{RecommendedFees, Utxo};
19use log::{debug, info};
20use sdk_common::bitcoin::hashes::hex::ToHex as _;
21
22use super::{BitcoinChainService, BtcScriptBalance, History};
23
24pub(crate) struct EsploraBitcoinChainService {
25    config: Config,
26    client: OnceLock<AsyncClient>,
27    last_known_tip: Mutex<Option<u32>>,
28}
29
30impl EsploraBitcoinChainService {
31    pub(crate) fn new(config: Config) -> Self {
32        Self {
33            config,
34            client: OnceLock::new(),
35            last_known_tip: Mutex::new(None),
36        }
37    }
38
39    fn get_client(&self) -> Result<&AsyncClient> {
40        if let Some(c) = self.client.get() {
41            return Ok(c);
42        }
43
44        let esplora_url = match &self.config.bitcoin_explorer {
45            BlockchainExplorer::Esplora { url, .. } => url,
46            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
47            BlockchainExplorer::Electrum { .. } => {
48                anyhow::bail!("Cannot start Bitcoin Esplora chain service without an Esplora url")
49            }
50        };
51        let client = Builder::new(esplora_url)
52            .connect_timeout(10)
53            .timeout(3)
54            .max_retries(2)
55            .build_async()?;
56        let client = self.client.get_or_init(|| client);
57        Ok(client)
58    }
59}
60
61#[sdk_macros::async_trait]
62impl BitcoinChainService for EsploraBitcoinChainService {
63    async fn tip(&self) -> Result<u32> {
64        debug!("BitcoinChainService::tip: start");
65        let client = self.get_client()?;
66        let new_tip = client.get_height().await.ok();
67
68        let mut last_tip = self.last_known_tip.lock().await;
69        let res = match new_tip {
70            Some(height) => {
71                *last_tip = Some(height);
72                Ok(height)
73            }
74            None => (*last_tip).ok_or_else(|| anyhow!("Failed to get tip")),
75        };
76        debug!("BitcoinChainService::tip: end");
77        res
78    }
79
80    async fn broadcast(&self, tx: &Transaction) -> Result<Txid> {
81        debug!("BitcoinChainService::broadcast: start");
82        self.get_client()?.broadcast(tx).await?;
83        debug!("BitcoinChainService::broadcast: end");
84        Ok(tx.compute_txid())
85    }
86
87    // TODO Switch to batch search
88    async fn get_transactions(&self, txids: &[Txid]) -> Result<Vec<Transaction>> {
89        debug!("BitcoinChainService::get_transactions: start");
90        let client = self.get_client()?;
91        let mut result = vec![];
92        for txid in txids {
93            let tx = client
94                .get_tx(txid)
95                .await?
96                .context("Transaction not found")?;
97            result.push(tx);
98        }
99        debug!("BitcoinChainService::get_transactions: end");
100        Ok(result)
101    }
102
103    async fn get_script_history(&self, script: &Script) -> Result<Vec<History>> {
104        debug!("BitcoinChainService::get_script_history: start");
105        let client = self.get_client()?;
106        let history = client
107            .scripthash_txs(script, None)
108            .await?
109            .into_iter()
110            .map(|tx| History {
111                txid: tx.txid,
112                height: tx.status.block_height.map(|h| h as i32).unwrap_or(-1),
113            })
114            .collect();
115        debug!("BitcoinChainService::get_script_history end");
116        Ok(history)
117    }
118
119    // TODO Switch to batch search
120    async fn get_scripts_history(&self, scripts: &[&Script]) -> Result<Vec<Vec<History>>> {
121        debug!("BitcoinChainService::get_scripts_history: start");
122        let mut result = vec![];
123        for script in scripts {
124            let history = self.get_script_history(script).await?;
125            result.push(history);
126        }
127        debug!("BitcoinChainService::get_scripts_history end");
128        Ok(result)
129    }
130
131    async fn get_script_history_with_retry(
132        &self,
133        script: &Script,
134        retries: u64,
135    ) -> Result<Vec<History>> {
136        let script_hash = sha256::Hash::hash(script.as_bytes()).to_hex();
137        debug!("BitcoinChainService::get_script_history_with_retry: start");
138        info!("Fetching script history for {}", script_hash);
139        let mut script_history = vec![];
140
141        let mut retry = 0;
142        while retry <= retries {
143            script_history = self.get_script_history(script).await?;
144            match script_history.is_empty() {
145                true => {
146                    retry += 1;
147                    info!(
148                        "Script history for {script_hash} got zero transactions, retrying in {retry} seconds..."
149                    );
150                    tokio::time::sleep(Duration::from_secs(retry)).await;
151                }
152                false => break,
153            }
154        }
155        debug!("BitcoinChainService::get_script_history_with_retry end");
156        Ok(script_history)
157    }
158
159    async fn get_script_utxos(&self, script: &Script) -> Result<Vec<Utxo>> {
160        debug!("BitcoinChainService::get_script_utxos: start");
161        let utxos = self
162            .get_scripts_utxos(&[script])
163            .await?
164            .first()
165            .cloned()
166            .unwrap_or_default();
167        debug!("BitcoinChainService::get_script_utxos: start");
168        Ok(utxos)
169    }
170
171    async fn get_scripts_utxos(&self, scripts: &[&Script]) -> Result<Vec<Vec<Utxo>>> {
172        debug!("BitcoinChainService::get_scripts_utxos: start");
173        let scripts_history = self.get_scripts_history(scripts).await?;
174        let tx_confirmed_map: HashMap<_, _> = scripts_history
175            .iter()
176            .flatten()
177            .map(|h| (h.txid, h.height > 0))
178            .collect();
179        let txs = self
180            .get_transactions(&tx_confirmed_map.keys().cloned().collect::<Vec<_>>())
181            .await?;
182        let script_txs_map: HashMap<ScriptBuf, Vec<Transaction>> = scripts
183            .iter()
184            .map(|script| ScriptBuf::from_bytes(script.to_bytes().to_vec()))
185            .zip(scripts_history)
186            .map(|(script_buf, script_history)| {
187                (
188                    script_buf,
189                    script_history
190                        .iter()
191                        .filter_map(|h| {
192                            txs.iter()
193                                .find(|tx| tx.compute_txid().as_raw_hash() == h.txid.as_raw_hash())
194                                .cloned()
195                        })
196                        .collect::<Vec<_>>(),
197                )
198            })
199            .collect();
200        let scripts_utxos = script_txs_map
201            .iter()
202            .map(|(script_buf, txs)| {
203                txs.iter()
204                    .flat_map(|tx| {
205                        tx.output
206                            .iter()
207                            .enumerate()
208                            .filter(|(_, output)| output.script_pubkey == *script_buf)
209                            .filter(|(vout, _)| {
210                                // Check if output is unspent (only consider confirmed spending txs)
211                                !txs.iter().any(|spending_tx| {
212                                    let spends_our_output = spending_tx.input.iter().any(|input| {
213                                        input.previous_output.txid == tx.compute_txid()
214                                            && input.previous_output.vout == *vout as u32
215                                    });
216
217                                    if spends_our_output {
218                                        // If it does spend our output, check if it's confirmed
219                                        let spending_tx_hash = spending_tx.compute_txid();
220                                        tx_confirmed_map
221                                            .get(&spending_tx_hash)
222                                            .copied()
223                                            .unwrap_or(false)
224                                    } else {
225                                        false
226                                    }
227                                })
228                            })
229                            .map(|(vout, output)| {
230                                Utxo::Bitcoin((
231                                    OutPoint::new(tx.compute_txid(), vout as u32),
232                                    output.clone(),
233                                ))
234                            })
235                    })
236                    .collect()
237            })
238            .collect();
239        debug!("BitcoinChainService::get_scripts_utxos end");
240        Ok(scripts_utxos)
241    }
242
243    async fn script_get_balance(&self, script: &Script) -> Result<BtcScriptBalance> {
244        debug!("BitcoinChainService::script_get_balance: start");
245        let client = self.get_client()?;
246        let utxos = client.scripthash_utxos(script).await?;
247        let mut balance = BtcScriptBalance {
248            confirmed: 0,
249            unconfirmed: 0,
250        };
251        for utxo in utxos {
252            match utxo.status.confirmed {
253                true => balance.confirmed += utxo.value,
254                false => balance.unconfirmed += utxo.value as i64,
255            };
256        }
257        debug!("BitcoinChainService::script_get_balance: end");
258        Ok(balance)
259    }
260
261    // TODO Switch to batch search
262    async fn scripts_get_balance(&self, scripts: &[&Script]) -> Result<Vec<BtcScriptBalance>> {
263        debug!("BitcoinChainService::scripts_get_balance: start");
264        let mut result = vec![];
265        for script in scripts {
266            let balance = self.script_get_balance(script).await?;
267            result.push(balance);
268        }
269        debug!("BitcoinChainService::scripts_get_balance: end");
270        Ok(result)
271    }
272
273    async fn script_get_balance_with_retry(
274        &self,
275        script: &Script,
276        retries: u64,
277    ) -> Result<BtcScriptBalance> {
278        debug!("BitcoinChainService::script_get_balance_with_retry: start");
279        let script_hash = sha256::Hash::hash(script.as_bytes()).to_hex();
280        info!("Fetching script balance for {}", script_hash);
281        let mut script_balance = BtcScriptBalance {
282            confirmed: 0,
283            unconfirmed: 0,
284        };
285
286        let mut retry = 0;
287        while retry <= retries {
288            script_balance = self.script_get_balance(script).await?;
289            match script_balance {
290                BtcScriptBalance {
291                    confirmed: 0,
292                    unconfirmed: 0,
293                } => {
294                    retry += 1;
295                    info!(
296                        "Got zero balance for script {script_hash}, retrying in {retry} seconds..."
297                    );
298                    tokio::time::sleep(Duration::from_secs(retry)).await;
299                }
300                _ => break,
301            }
302        }
303        debug!("BitcoinChainService::script_get_balance_with_retry: end");
304        Ok(script_balance)
305    }
306
307    async fn verify_tx(
308        &self,
309        address: &Address,
310        tx_id: &str,
311        tx_hex: &str,
312        verify_confirmation: bool,
313    ) -> Result<Transaction> {
314        debug!("BitcoinChainService::verify_tx: start");
315        let script = address.script_pubkey();
316        let script_history = self.get_script_history_with_retry(&script, 10).await?;
317        let lockup_tx_history = script_history.iter().find(|h| h.txid.to_hex().eq(tx_id));
318
319        let res = match lockup_tx_history {
320            Some(history) => {
321                info!("Bitcoin transaction found, verifying transaction content...");
322                let tx: Transaction = deserialize(&hex::decode(tx_hex)?)?;
323                let tx_hex = tx.compute_txid().to_hex();
324                if !tx_hex.eq(&history.txid.to_hex()) {
325                    return Err(anyhow!(
326                        "Bitcoin transaction id and hex do not match: {} vs {}",
327                        tx_id,
328                        tx_hex
329                    ));
330                }
331
332                if verify_confirmation && history.height <= 0 {
333                    return Err(anyhow!(
334                        "Bitcoin transaction was not confirmed, txid={} waiting for confirmation",
335                        tx_id,
336                    ));
337                }
338                Ok(tx)
339            }
340            None => Err(anyhow!(
341                "Bitcoin transaction was not found, txid={} waiting for broadcast",
342                tx_id,
343            )),
344        };
345        debug!("BitcoinChainService::verify_tx: end");
346        res
347    }
348
349    async fn recommended_fees(&self) -> Result<RecommendedFees> {
350        debug!("BitcoinChainService::recommended_fees: start");
351        let client = self.get_client()?;
352        let fees = client.get_fee_estimates().await?;
353        let get_fees = |block: &u16| fees.get(block).map(|fee| fee.ceil() as u64).unwrap_or(0);
354
355        debug!("BitcoinChainService::recommended_fees: end");
356        Ok(RecommendedFees {
357            fastest_fee: get_fees(&1),
358            half_hour_fee: get_fees(&3),
359            hour_fee: get_fees(&6),
360            economy_fee: get_fees(&25),
361            minimum_fee: get_fees(&1008),
362        })
363    }
364}