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