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