breez_sdk_liquid/chain/bitcoin/
electrum.rs

1#![cfg(not(all(target_family = "wasm", target_os = "unknown")))]
2
3use std::{collections::HashMap, sync::OnceLock, time::Duration};
4
5use anyhow::{anyhow, bail, Result};
6use tokio::sync::Mutex;
7
8use crate::{
9    bitcoin::{
10        consensus::{deserialize, serialize},
11        hashes::{sha256, Hash},
12        Address, OutPoint, Script, ScriptBuf, Transaction, Txid,
13    },
14    model::{BlockchainExplorer, Config, RecommendedFees, Utxo},
15};
16
17use electrum_client::{Client, ElectrumApi, HeaderNotification};
18use log::info;
19use lwk_wollet::{ElectrumOptions, ElectrumUrl};
20use sdk_common::bitcoin::hashes::hex::ToHex as _;
21
22use super::{BitcoinChainService, BtcScriptBalance, History};
23
24pub(crate) struct ElectrumBitcoinChainService {
25    config: Config,
26    client: OnceLock<Client>,
27    last_known_tip: Mutex<Option<u32>>,
28}
29
30impl ElectrumBitcoinChainService {
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<&Client> {
40        if let Some(c) = self.client.get() {
41            return Ok(c);
42        }
43
44        let (tls, validate_domain) = self.config.electrum_tls_options();
45        let electrum_url = match &self.config.bitcoin_explorer {
46            BlockchainExplorer::Electrum { url } => ElectrumUrl::new(url, tls, validate_domain)?,
47            _ => bail!("Cannot start Bitcoin Electrum chain service without an Electrum url"),
48        };
49        let client = electrum_url.build_client(&ElectrumOptions { timeout: Some(3) })?;
50
51        let client = self.client.get_or_init(|| client);
52        Ok(client)
53    }
54}
55
56#[sdk_macros::async_trait]
57impl BitcoinChainService for ElectrumBitcoinChainService {
58    async fn tip(&self) -> Result<u32> {
59        let client = self.get_client()?;
60        let mut maybe_popped_header = None;
61        while let Some(header) = client.block_headers_pop_raw()? {
62            maybe_popped_header = Some(header)
63        }
64
65        let new_tip: Option<HeaderNotification> = match maybe_popped_header {
66            Some(popped_header) => Some(popped_header.try_into()?),
67            None => {
68                // https://github.com/bitcoindevkit/rust-electrum-client/issues/124
69                // It might be that the client has reconnected and subscriptions don't persist
70                // across connections. Calling `client.ping()` won't help here because the
71                // successful retry will prevent us knowing about the reconnect.
72                if let Ok(header) = client.block_headers_subscribe_raw() {
73                    Some(header.try_into()?)
74                } else {
75                    None
76                }
77            }
78        };
79
80        let mut last_tip = self.last_known_tip.lock().await;
81        match new_tip {
82            Some(header) => {
83                let height = header.height as u32;
84                *last_tip = Some(height);
85                Ok(height)
86            }
87            None => (*last_tip).ok_or_else(|| anyhow!("Failed to get tip")),
88        }
89    }
90
91    async fn broadcast(&self, tx: &Transaction) -> Result<Txid> {
92        let txid = self
93            .get_client()?
94            .transaction_broadcast_raw(&serialize(&tx))?;
95        Ok(Txid::from_raw_hash(txid.to_raw_hash()))
96    }
97
98    async fn get_transactions(&self, txids: &[Txid]) -> Result<Vec<Transaction>> {
99        let mut result = vec![];
100        for tx in self.get_client()?.batch_transaction_get_raw(txids)? {
101            let tx: Transaction = deserialize(&tx)?;
102            result.push(tx);
103        }
104        Ok(result)
105    }
106
107    async fn get_script_history(&self, script: &Script) -> Result<Vec<History>> {
108        Ok(self
109            .get_client()?
110            .script_get_history(script)?
111            .into_iter()
112            .map(Into::into)
113            .collect())
114    }
115
116    async fn get_scripts_history(&self, scripts: &[&Script]) -> Result<Vec<Vec<History>>> {
117        Ok(self
118            .get_client()?
119            .batch_script_get_history(scripts)?
120            .into_iter()
121            .map(|v| v.into_iter().map(Into::into).collect())
122            .collect())
123    }
124
125    async fn get_script_history_with_retry(
126        &self,
127        script: &Script,
128        retries: u64,
129    ) -> Result<Vec<History>> {
130        let script_hash = sha256::Hash::hash(script.as_bytes()).to_hex();
131        info!("Fetching script history for {}", script_hash);
132        let mut script_history = vec![];
133
134        let mut retry = 0;
135        while retry <= retries {
136            script_history = self.get_script_history(script).await?;
137            match script_history.is_empty() {
138                true => {
139                    retry += 1;
140                    info!(
141                        "Script history for {} got zero transactions, retrying in {} seconds...",
142                        script_hash, retry
143                    );
144                    tokio::time::sleep(Duration::from_secs(retry)).await;
145                }
146                false => break,
147            }
148        }
149        Ok(script_history)
150    }
151
152    async fn get_script_utxos(&self, script: &Script) -> Result<Vec<Utxo>> {
153        Ok(self
154            .get_scripts_utxos(&[script])
155            .await?
156            .first()
157            .cloned()
158            .unwrap_or_default())
159    }
160
161    async fn get_scripts_utxos(&self, scripts: &[&Script]) -> Result<Vec<Vec<Utxo>>> {
162        let scripts_history = self.get_scripts_history(scripts).await?;
163        let tx_confirmed_map: HashMap<_, _> = scripts_history
164            .iter()
165            .flatten()
166            .map(|h| (Txid::from_raw_hash(h.txid.to_raw_hash()), h.height > 0))
167            .collect();
168        let txs = self
169            .get_transactions(&tx_confirmed_map.keys().cloned().collect::<Vec<_>>())
170            .await?;
171        let script_txs_map: HashMap<ScriptBuf, Vec<Transaction>> = scripts
172            .iter()
173            .map(|script| ScriptBuf::from_bytes(script.to_bytes().to_vec()))
174            .zip(scripts_history)
175            .map(|(script_buf, script_history)| {
176                (
177                    script_buf,
178                    script_history
179                        .iter()
180                        .filter_map(|h| {
181                            txs.iter()
182                                .find(|tx| tx.compute_txid().as_raw_hash() == h.txid.as_raw_hash())
183                                .cloned()
184                        })
185                        .collect::<Vec<_>>(),
186                )
187            })
188            .collect();
189        let scripts_utxos = script_txs_map
190            .iter()
191            .map(|(script_buf, txs)| {
192                txs.iter()
193                    .flat_map(|tx| {
194                        tx.output
195                            .iter()
196                            .enumerate()
197                            .filter(|(_, output)| output.script_pubkey == *script_buf)
198                            .filter(|(vout, _)| {
199                                // Check if output is unspent (only consider confirmed spending txs)
200                                !txs.iter().any(|spending_tx| {
201                                    let spends_our_output = spending_tx.input.iter().any(|input| {
202                                        input.previous_output.txid == tx.compute_txid()
203                                            && input.previous_output.vout == *vout as u32
204                                    });
205
206                                    if spends_our_output {
207                                        // If it does spend our output, check if it's confirmed
208                                        let spending_tx_hash = spending_tx.compute_txid();
209                                        tx_confirmed_map
210                                            .get(&spending_tx_hash)
211                                            .copied()
212                                            .unwrap_or(false)
213                                    } else {
214                                        false
215                                    }
216                                })
217                            })
218                            .map(|(vout, output)| {
219                                Utxo::Bitcoin((
220                                    OutPoint::new(tx.compute_txid(), vout as u32),
221                                    output.clone(),
222                                ))
223                            })
224                    })
225                    .collect()
226            })
227            .collect();
228        Ok(scripts_utxos)
229    }
230
231    async fn script_get_balance(&self, script: &Script) -> Result<BtcScriptBalance> {
232        Ok(self.get_client()?.script_get_balance(script)?.into())
233    }
234
235    async fn scripts_get_balance(&self, scripts: &[&Script]) -> Result<Vec<BtcScriptBalance>> {
236        Ok(self
237            .get_client()?
238            .batch_script_get_balance(scripts)?
239            .into_iter()
240            .map(Into::into)
241            .collect())
242    }
243
244    async fn script_get_balance_with_retry(
245        &self,
246        script: &Script,
247        retries: u64,
248    ) -> Result<BtcScriptBalance> {
249        let script_hash = sha256::Hash::hash(script.as_bytes()).to_hex();
250        info!("Fetching script balance for {}", script_hash);
251        let mut script_balance = BtcScriptBalance {
252            confirmed: 0,
253            unconfirmed: 0,
254        };
255
256        let mut retry = 0;
257        while retry <= retries {
258            script_balance = self.script_get_balance(script).await?;
259            match script_balance {
260                BtcScriptBalance {
261                    confirmed: 0,
262                    unconfirmed: 0,
263                } => {
264                    retry += 1;
265                    info!(
266                        "Got zero balance for script {}, retrying in {} seconds...",
267                        script_hash, retry
268                    );
269                    tokio::time::sleep(Duration::from_secs(retry)).await;
270                }
271                _ => break,
272            }
273        }
274        Ok(script_balance)
275    }
276
277    async fn verify_tx(
278        &self,
279        address: &Address,
280        tx_id: &str,
281        tx_hex: &str,
282        verify_confirmation: bool,
283    ) -> Result<Transaction> {
284        let script = address.script_pubkey();
285        let script_history = self.get_script_history_with_retry(&script, 10).await?;
286        let lockup_tx_history = script_history.iter().find(|h| h.txid.to_hex().eq(tx_id));
287
288        match lockup_tx_history {
289            Some(history) => {
290                info!("Bitcoin transaction found, verifying transaction content...");
291                let tx: Transaction = deserialize(&hex::decode(tx_hex)?)?;
292                let tx_hex = tx.compute_txid().to_hex();
293                if !tx_hex.eq(&history.txid.to_hex()) {
294                    return Err(anyhow!(
295                        "Bitcoin transaction id and hex do not match: {} vs {}",
296                        tx_id,
297                        tx_hex
298                    ));
299                }
300
301                if verify_confirmation && history.height <= 0 {
302                    return Err(anyhow!(
303                        "Bitcoin transaction was not confirmed, txid={} waiting for confirmation",
304                        tx_id,
305                    ));
306                }
307                Ok(tx)
308            }
309            None => Err(anyhow!(
310                "Bitcoin transaction was not found, txid={} waiting for broadcast",
311                tx_id,
312            )),
313        }
314    }
315
316    async fn recommended_fees(&self) -> Result<RecommendedFees> {
317        let fees: Vec<u64> = self
318            .get_client()?
319            .batch_estimate_fee([1, 3, 6, 25, 1008])?
320            .into_iter()
321            .map(|v| v.ceil() as u64)
322            .collect();
323        Ok(RecommendedFees {
324            fastest_fee: fees[0],
325            half_hour_fee: fees[1],
326            hour_fee: fees[2],
327            economy_fee: fees[3],
328            minimum_fee: fees[4],
329        })
330    }
331}