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#[apply(async_trait_maybe_send!)]
56pub trait IBitcoindRpc: Debug + Send + Sync + 'static {
57 async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>>;
59
60 async fn watch_script_history(&self, script: &ScriptBuf) -> Result<()>;
62
63 async fn get_script_history(&self, script: &ScriptBuf) -> Result<Vec<Transaction>>;
65
66 async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof>;
68
69 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
81pub 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 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 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}