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