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