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
198#[derive(Debug)]
199pub struct EsploraClient {
200    client: AsyncClient,
201}
202
203impl EsploraClient {
204    pub fn new(url: &SafeUrl) -> Result<Self, BitcoinRpcError> {
205        let client = Builder::new(url.as_str().trim_end_matches('/'))
206            .timeout(ESPLORA_CLIENT_TIMEOUT_SECONDS)
207            .build_async()
208            .map_err(|source| BitcoinRpcError::InvalidUrl {
209                url: url.to_string(),
210                source: Box::new(source),
211            })?;
212
213        Ok(Self { client })
214    }
215}
216
217#[apply(async_trait_maybe_send!)]
218impl IBitcoindRpc for EsploraClient {
219    async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>, BitcoinRpcError> {
220        Ok(self
221            .client
222            .get_tx_status(txid)
223            .await
224            .map_err(|err| BitcoinRpcError::Backend(Box::new(err)))?
225            .block_height
226            .map(u64::from))
227    }
228
229    async fn watch_script_history(&self, _: &ScriptBuf) -> Result<(), BitcoinRpcError> {
230        // no watching needed, has all the history already
231        Ok(())
232    }
233
234    async fn get_script_history(
235        &self,
236        script: &ScriptBuf,
237    ) -> Result<Vec<bitcoin::Transaction>, BitcoinRpcError> {
238        const MAX_TX_HISTORY: usize = 1000;
239
240        let mut transactions = Vec::new();
241        let mut last_seen: Option<Txid> = None;
242
243        loop {
244            let page = self
245                .client
246                .scripthash_txs(script, last_seen)
247                .await
248                .map_err(|err| BitcoinRpcError::Backend(Box::new(err)))?;
249
250            if page.is_empty() {
251                break;
252            }
253
254            for tx in &page {
255                transactions.push(tx.to_tx());
256            }
257
258            if transactions.len() >= MAX_TX_HISTORY {
259                return Err(BitcoinRpcError::ScriptHistoryTooLong {
260                    max: MAX_TX_HISTORY,
261                });
262            }
263
264            last_seen = Some(page.last().expect("page not empty").txid);
265        }
266
267        Ok(transactions)
268    }
269
270    async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof, BitcoinRpcError> {
271        let proof = self
272            .client
273            .get_merkle_block(&txid)
274            .await
275            .map_err(|err| BitcoinRpcError::Backend(Box::new(err)))?
276            .ok_or(BitcoinRpcError::ProofNotFound { txid })?;
277
278        Ok(TxOutProof {
279            block_header: proof.header,
280            merkle_proof: proof.txn,
281        })
282    }
283
284    async fn get_info(&self) -> Result<BlockchainInfo, BitcoinRpcError> {
285        let height = self
286            .client
287            .get_height()
288            .await
289            .map_err(|err| BitcoinRpcError::Backend(Box::new(err)))?;
290        Ok(BlockchainInfo {
291            block_height: u64::from(height),
292            synced: true,
293        })
294    }
295}