Skip to main content

fedimint_bitcoind/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_truncation)]
3#![allow(clippy::cast_sign_loss)]
4#![allow(clippy::missing_errors_doc)]
5#![allow(clippy::missing_panics_doc)]
6#![allow(clippy::module_name_repetitions)]
7#![allow(clippy::similar_names)]
8
9pub mod error;
10pub mod metrics;
11
12use std::env;
13use std::fmt::Debug;
14use std::sync::Arc;
15
16use bitcoin::{ScriptBuf, Transaction, Txid};
17use esplora_client::{AsyncClient, Builder};
18use fedimint_core::envs::FM_FORCE_BITCOIN_RPC_URL_ENV;
19use fedimint_core::time::now;
20use fedimint_core::txoproof::TxOutProof;
21use fedimint_core::util::{FmtCompactResult as _, SafeUrl};
22use fedimint_core::{apply, async_trait_maybe_send};
23use fedimint_logging::LOG_BITCOIND;
24use fedimint_metrics::HistogramExt as _;
25use tracing::trace;
26
27pub use crate::error::BitcoinRpcError;
28use crate::metrics::{BITCOIND_RPC_DURATION_SECONDS, BITCOIND_RPC_REQUESTS_TOTAL};
29
30const ESPLORA_CLIENT_TIMEOUT_SECONDS: u64 = 60;
31
32#[cfg(feature = "bitcoincore")]
33pub mod bitcoincore;
34
35#[derive(Debug, Clone)]
36pub struct BlockchainInfo {
37    pub block_height: u64,
38    pub synced: bool,
39}
40
41pub fn create_esplora_rpc(url: &SafeUrl) -> Result<DynBitcoindRpc, BitcoinRpcError> {
42    let url = match env::var(FM_FORCE_BITCOIN_RPC_URL_ENV).ok() {
43        // The raw value may carry credentials, so the error names the variable instead.
44        Some(s) => SafeUrl::parse(&s).map_err(|source| BitcoinRpcError::InvalidUrl {
45            url: format!("${FM_FORCE_BITCOIN_RPC_URL_ENV}"),
46            source: Box::new(source),
47        })?,
48        None => url.clone(),
49    };
50
51    Ok(EsploraClient::new(&url)?.into_dyn())
52}
53
54pub type DynBitcoindRpc = Arc<dyn IBitcoindRpc + Send + Sync>;
55
56/// Trait that allows interacting with the Bitcoin blockchain
57///
58/// Functions may panic if the bitcoind node is not reachable.
59#[apply(async_trait_maybe_send!)]
60pub trait IBitcoindRpc: Debug + Send + Sync + 'static {
61    /// If a transaction is included in a block, returns the block height.
62    async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>, BitcoinRpcError>;
63
64    /// Watches for a script and returns any transaction associated with it
65    async fn watch_script_history(&self, script: &ScriptBuf) -> Result<(), BitcoinRpcError>;
66
67    /// Get script transaction history
68    async fn get_script_history(
69        &self,
70        script: &ScriptBuf,
71    ) -> Result<Vec<Transaction>, BitcoinRpcError>;
72
73    /// Returns a proof that a tx is included in the bitcoin blockchain
74    async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof, BitcoinRpcError>;
75
76    /// Returns `BlockchainInfo` which contains a subset of info about the chain
77    /// data source.
78    async fn get_info(&self) -> Result<BlockchainInfo, BitcoinRpcError>;
79
80    fn into_dyn(self) -> DynBitcoindRpc
81    where
82        Self: Sized,
83    {
84        Arc::new(self)
85    }
86}
87
88/// A wrapper around `DynBitcoindRpc` that tracks metrics for each RPC call.
89///
90/// This wrapper records the duration and success/error status of each
91/// Bitcoin RPC call to Prometheus metrics, allowing monitoring of
92/// Bitcoin node connectivity and performance.
93pub struct BitcoindTracked {
94    inner: DynBitcoindRpc,
95    name: &'static str,
96}
97
98impl std::fmt::Debug for BitcoindTracked {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("BitcoindTracked")
101            .field("name", &self.name)
102            .field("inner", &self.inner)
103            .finish()
104    }
105}
106
107impl BitcoindTracked {
108    /// Wraps a `DynBitcoindRpc` with metrics tracking.
109    ///
110    /// The `name` parameter is used to distinguish different uses of the
111    /// Bitcoin RPC client in metrics (e.g., "wallet-client", "recovery").
112    pub fn new(inner: DynBitcoindRpc, name: &'static str) -> Self {
113        Self { inner, name }
114    }
115
116    fn record_call<T>(&self, method: &str, result: &Result<T, BitcoinRpcError>) {
117        let result_label = if result.is_ok() { "success" } else { "error" };
118        BITCOIND_RPC_REQUESTS_TOTAL
119            .with_label_values(&[method, self.name, result_label])
120            .inc();
121    }
122}
123
124macro_rules! tracked_call {
125    ($self:ident, $method:expr, $call:expr) => {{
126        trace!(
127            target: LOG_BITCOIND,
128            method = $method,
129            name = $self.name,
130            "starting bitcoind rpc"
131        );
132        let start = now();
133        let timer = BITCOIND_RPC_DURATION_SECONDS
134            .with_label_values(&[$method, $self.name])
135            .start_timer_ext();
136        let result = $call;
137        timer.observe_duration();
138        $self.record_call($method, &result);
139        let duration_ms = now()
140            .duration_since(start)
141            .unwrap_or_default()
142            .as_secs_f64()
143            * 1000.0;
144        trace!(
145            target: LOG_BITCOIND,
146            method = $method,
147            name = $self.name,
148            duration_ms,
149            error = %result.fmt_compact_result(),
150            "completed bitcoind rpc"
151        );
152        result
153    }};
154}
155
156#[apply(async_trait_maybe_send!)]
157impl IBitcoindRpc for BitcoindTracked {
158    async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>, BitcoinRpcError> {
159        tracked_call!(
160            self,
161            "get_tx_block_height",
162            self.inner.get_tx_block_height(txid).await
163        )
164    }
165
166    async fn watch_script_history(&self, script: &ScriptBuf) -> Result<(), BitcoinRpcError> {
167        tracked_call!(
168            self,
169            "watch_script_history",
170            self.inner.watch_script_history(script).await
171        )
172    }
173
174    async fn get_script_history(
175        &self,
176        script: &ScriptBuf,
177    ) -> Result<Vec<Transaction>, BitcoinRpcError> {
178        tracked_call!(
179            self,
180            "get_script_history",
181            self.inner.get_script_history(script).await
182        )
183    }
184
185    async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof, BitcoinRpcError> {
186        tracked_call!(
187            self,
188            "get_txout_proof",
189            self.inner.get_txout_proof(txid).await
190        )
191    }
192
193    async fn get_info(&self) -> Result<BlockchainInfo, BitcoinRpcError> {
194        tracked_call!(self, "get_info", self.inner.get_info().await)
195    }
196}
197
198pub struct EsploraClient {
199    client: AsyncClient,
200}
201
202impl Debug for EsploraClient {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        f.debug_struct("EsploraClient").finish_non_exhaustive()
205    }
206}
207
208impl EsploraClient {
209    pub fn new(url: &SafeUrl) -> Result<Self, BitcoinRpcError> {
210        let builder = Builder::new(url.as_str().trim_end_matches('/'));
211        #[cfg(not(target_family = "wasm"))]
212        let builder = builder.timeout(std::time::Duration::from_secs(
213            ESPLORA_CLIENT_TIMEOUT_SECONDS,
214        ));
215        #[cfg(target_family = "wasm")]
216        let builder = builder.timeout(ESPLORA_CLIENT_TIMEOUT_SECONDS);
217
218        let client = builder
219            .build_async()
220            .map_err(|source| BitcoinRpcError::InvalidUrl {
221                url: url.to_string(),
222                source: Box::new(source),
223            })?;
224
225        Ok(Self { client })
226    }
227}
228
229#[apply(async_trait_maybe_send!)]
230impl IBitcoindRpc for EsploraClient {
231    async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>, BitcoinRpcError> {
232        Ok(self
233            .client
234            .get_tx_status(txid)
235            .await
236            .map_err(|err| BitcoinRpcError::Backend(Box::new(err)))?
237            .block_height
238            .map(u64::from))
239    }
240
241    async fn watch_script_history(&self, _: &ScriptBuf) -> Result<(), BitcoinRpcError> {
242        // no watching needed, has all the history already
243        Ok(())
244    }
245
246    async fn get_script_history(
247        &self,
248        script: &ScriptBuf,
249    ) -> Result<Vec<bitcoin::Transaction>, BitcoinRpcError> {
250        const MAX_TX_HISTORY: usize = 1000;
251
252        let mut transactions = Vec::new();
253        let mut last_seen: Option<Txid> = None;
254
255        loop {
256            #[cfg(not(target_family = "wasm"))]
257            let page = self.client.get_scripthash_txs(script, last_seen);
258            #[cfg(target_family = "wasm")]
259            let page = self.client.scripthash_txs(script, last_seen);
260            let page = page
261                .await
262                .map_err(|err| BitcoinRpcError::Backend(Box::new(err)))?;
263
264            if page.is_empty() {
265                break;
266            }
267
268            for tx in &page {
269                transactions.push(tx.to_tx());
270            }
271
272            if transactions.len() >= MAX_TX_HISTORY {
273                return Err(BitcoinRpcError::ScriptHistoryTooLong {
274                    max: MAX_TX_HISTORY,
275                });
276            }
277
278            last_seen = Some(page.last().expect("page not empty").txid);
279        }
280
281        Ok(transactions)
282    }
283
284    async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof, BitcoinRpcError> {
285        let proof = self
286            .client
287            .get_merkle_block(&txid)
288            .await
289            .map_err(|err| BitcoinRpcError::Backend(Box::new(err)))?
290            .ok_or(BitcoinRpcError::ProofNotFound { txid })?;
291
292        Ok(TxOutProof {
293            block_header: proof.header,
294            merkle_proof: proof.txn,
295        })
296    }
297
298    async fn get_info(&self) -> Result<BlockchainInfo, BitcoinRpcError> {
299        let height = self
300            .client
301            .get_height()
302            .await
303            .map_err(|err| BitcoinRpcError::Backend(Box::new(err)))?;
304        Ok(BlockchainInfo {
305            block_height: u64::from(height),
306            synced: true,
307        })
308    }
309}