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 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#[apply(async_trait_maybe_send!)]
60pub trait IBitcoindRpc: Debug + Send + Sync + 'static {
61 async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>, BitcoinRpcError>;
63
64 async fn watch_script_history(&self, script: &ScriptBuf) -> Result<(), BitcoinRpcError>;
66
67 async fn get_script_history(
69 &self,
70 script: &ScriptBuf,
71 ) -> Result<Vec<Transaction>, BitcoinRpcError>;
72
73 async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof, BitcoinRpcError>;
75
76 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
88pub 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 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 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}