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