breez_sdk_liquid/chain/bitcoin/
esplora.rs1use 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 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 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 !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 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 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}