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