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 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 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 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 !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 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 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}