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