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
9use std::collections::BTreeMap;
10use std::fmt::Debug;
11use std::sync::{Arc, LazyLock, Mutex};
12use std::time::Duration;
13use std::{env, iter};
14
15use anyhow::{Context, Result};
16use bitcoin::{Block, BlockHash, Network, ScriptBuf, Transaction, Txid};
17use fedimint_core::envs::{
18 is_running_in_test_env, BitcoinRpcConfig, FM_BITCOIN_POLLING_INTERVAL_SECS_ENV,
19 FM_FORCE_BITCOIN_RPC_KIND_ENV, FM_FORCE_BITCOIN_RPC_URL_ENV, FM_WALLET_FEERATE_SOURCES_ENV,
20};
21use fedimint_core::task::TaskGroup;
22use fedimint_core::time::now;
23use fedimint_core::txoproof::TxOutProof;
24use fedimint_core::util::{FmtCompact as _, FmtCompactAnyhow, SafeUrl};
25use fedimint_core::{apply, async_trait_maybe_send, dyn_newtype_define, Feerate};
26use fedimint_logging::{LOG_BITCOIND, LOG_CORE};
27use feerate_source::{FeeRateSource, FetchJson};
28use tokio::sync::watch;
29use tokio::time::Interval;
30use tracing::{debug, trace, warn};
31
32#[cfg(feature = "bitcoincore-rpc")]
33pub mod bitcoincore;
34#[cfg(feature = "esplora-client")]
35mod esplora;
36mod feerate_source;
37
38const MAINNET_GENESIS_BLOCK_HASH: &str =
40 "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";
41const TESTNET_GENESIS_BLOCK_HASH: &str =
43 "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943";
44const SIGNET_GENESIS_BLOCK_HASH: &str =
46 "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6";
47const REGTEST_GENESIS_BLOCK_HASH: &str =
50 "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206";
51
52static BITCOIN_RPC_REGISTRY: LazyLock<Mutex<BTreeMap<String, DynBitcoindRpcFactory>>> =
54 LazyLock::new(|| {
55 Mutex::new(BTreeMap::from([
56 #[cfg(feature = "esplora-client")]
57 ("esplora".to_string(), esplora::EsploraFactory.into()),
58 #[cfg(feature = "bitcoincore-rpc")]
59 ("bitcoind".to_string(), bitcoincore::BitcoindFactory.into()),
60 ]))
61 });
62
63pub fn create_bitcoind(config: &BitcoinRpcConfig) -> Result<DynBitcoindRpc> {
65 let registry = BITCOIN_RPC_REGISTRY.lock().expect("lock poisoned");
66
67 let kind = env::var(FM_FORCE_BITCOIN_RPC_KIND_ENV)
68 .ok()
69 .unwrap_or_else(|| config.kind.clone());
70 let url = env::var(FM_FORCE_BITCOIN_RPC_URL_ENV)
71 .ok()
72 .map(|s| SafeUrl::parse(&s))
73 .transpose()?
74 .unwrap_or_else(|| config.url.clone());
75 debug!(target: LOG_CORE, %kind, %url, "Starting bitcoin rpc");
76 let maybe_factory = registry.get(&kind);
77 let factory = maybe_factory.with_context(|| {
78 anyhow::anyhow!(
79 "{} rpc not registered, available options: {:?}",
80 config.kind,
81 registry.keys()
82 )
83 })?;
84 factory.create_connection(&url)
85}
86
87pub fn register_bitcoind(kind: String, factory: DynBitcoindRpcFactory) {
89 let mut registry = BITCOIN_RPC_REGISTRY.lock().expect("lock poisoned");
90 registry.insert(kind, factory);
91}
92
93pub trait IBitcoindRpcFactory: Debug + Send + Sync {
95 fn create_connection(&self, url: &SafeUrl) -> Result<DynBitcoindRpc>;
97}
98
99dyn_newtype_define! {
100 #[derive(Clone)]
101 pub DynBitcoindRpcFactory(Arc<IBitcoindRpcFactory>)
102}
103
104#[apply(async_trait_maybe_send!)]
108pub trait IBitcoindRpc: Debug {
109 async fn get_network(&self) -> Result<bitcoin::Network>;
111
112 async fn get_block_count(&self) -> Result<u64>;
114
115 async fn get_block_hash(&self, height: u64) -> Result<BlockHash>;
127
128 async fn get_block(&self, block_hash: &BlockHash) -> Result<Block>;
129
130 async fn get_fee_rate(&self, confirmation_target: u16) -> Result<Option<Feerate>>;
135
136 async fn submit_transaction(&self, transaction: Transaction);
151
152 async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>>;
156
157 async fn is_tx_in_block(
159 &self,
160 txid: &Txid,
161 block_hash: &BlockHash,
162 block_height: u64,
163 ) -> Result<bool>;
164
165 async fn watch_script_history(&self, script: &ScriptBuf) -> Result<()>;
172
173 async fn get_script_history(&self, script: &ScriptBuf) -> Result<Vec<Transaction>>;
178
179 async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof>;
181
182 async fn get_sync_percentage(&self) -> Result<Option<f64>>;
185
186 fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig;
188}
189
190dyn_newtype_define! {
191 #[derive(Clone)]
192 pub DynBitcoindRpc(Arc<IBitcoindRpc>)
193}
194
195impl DynBitcoindRpc {
196 pub fn spawn_block_count_update_task(
199 self,
200 task_group: &TaskGroup,
201 ) -> anyhow::Result<watch::Receiver<Option<u64>>> {
202 let (block_count_tx, block_count_rx) = watch::channel(None);
203 let mut desired_interval = get_bitcoin_polling_interval();
204
205 task_group.spawn_cancellable("block count background task", {
206 async move {
207 debug!(target: LOG_BITCOIND, "Updating bitcoin block count");
208
209 let update_block_count = || async {
210 let res = self
211 .get_block_count()
212 .await;
213
214 match res {
215 Ok(c) => {
216 let _ = block_count_tx.send(Some(c));
217 },
218 Err(err) => {
219 warn!(target: LOG_BITCOIND, err = %err.fmt_compact_anyhow(), "Unable to get block count from the node");
220 }
221 }
222 };
223
224 loop {
225 let start = now();
226 update_block_count().await;
227 let duration = now().duration_since(start).unwrap_or_default();
228 if Duration::from_secs(10) < duration {
229 warn!(target: LOG_BITCOIND, duration_secs=duration.as_secs(), "Updating block count from bitcoind slow");
230 }
231 desired_interval.tick().await;
232 }
233 }
234 });
235 Ok(block_count_rx)
236 }
237
238 pub fn spawn_fee_rate_update_task(
241 self,
242 task_group: &TaskGroup,
243 default_fee: Feerate,
244 network: Network,
245 confirmation_target: u16,
246 ) -> anyhow::Result<watch::Receiver<Feerate>> {
247 let (fee_rate_tx, fee_rate_rx) = watch::channel(default_fee);
248
249 let sources = std::env::var(FM_WALLET_FEERATE_SOURCES_ENV)
250 .unwrap_or_else(|_| match network {
251 Network::Bitcoin => "https://mempool.space/api/v1/fees/recommended#.;https://blockstream.info/api/fee-estimates#.\"1\"".to_owned(),
252 _ => String::new(),
253 })
254 .split(';')
255 .filter(|s| !s.is_empty())
256 .map(|s| Ok(Box::new(FetchJson::from_str(s)?) as Box<dyn FeeRateSource>))
257 .chain(iter::once(Ok(
258 Box::new(self.clone()) as Box<dyn FeeRateSource>
259 )))
260 .collect::<anyhow::Result<Vec<Box<dyn FeeRateSource>>>>()?;
261 let feerates = Arc::new(std::sync::Mutex::new(vec![None; sources.len()]));
262
263 let mut desired_interval = get_bitcoin_polling_interval();
264
265 task_group.spawn_cancellable("feerate background task", async move {
266 debug!(target: LOG_BITCOIND, "Updating feerate");
267
268 let update_fee_rate = || async {
269 trace!(target: LOG_BITCOIND, "Updating bitcoin fee rate");
270
271 let feerates_new = futures::future::join_all(sources.iter().map(|s| async { (s.name(), s.fetch(confirmation_target).await) } )).await;
272
273 let mut feerates = feerates.lock().expect("lock poisoned");
274 for (i, (name, res)) in feerates_new.into_iter().enumerate() {
275 match res {
276 Ok(ok) => feerates[i] = Some(ok),
277 Err(err) => {
278 if !is_running_in_test_env() {
280 warn!(target: LOG_BITCOIND, err = %err.fmt_compact_anyhow(), %name, "Error getting feerate from source");
281 }
282 },
283 }
284 }
285
286 let mut available_feerates : Vec<_> = feerates.iter().filter_map(Clone::clone).map(|r| r.sats_per_kvb).collect();
287
288 available_feerates.sort_unstable();
289
290 if let Some(r) = get_median(&available_feerates) {
291 let feerate = Feerate { sats_per_kvb: r };
292 let _ = fee_rate_tx.send(feerate);
293 } else {
294 if !is_running_in_test_env() {
296 warn!(target: LOG_BITCOIND, "Unable to calculate any fee rate");
297 }
298 }
299 };
300
301 loop {
302 let start = now();
303 update_fee_rate().await;
304 let duration = now().duration_since(start).unwrap_or_default();
305 if Duration::from_secs(10) < duration {
306 warn!(target: LOG_BITCOIND, duration_secs=duration.as_secs(), "Updating feerate from bitcoind slow");
307 }
308 desired_interval.tick().await;
309 }
310 });
311
312 Ok(fee_rate_rx)
313 }
314}
315
316fn get_bitcoin_polling_interval() -> Interval {
317 fn get_bitcoin_polling_period() -> Duration {
318 if let Ok(s) = env::var(FM_BITCOIN_POLLING_INTERVAL_SECS_ENV) {
319 use std::str::FromStr;
320 match u64::from_str(&s) {
321 Ok(secs) => return Duration::from_secs(secs),
322 Err(err) => {
323 warn!(
324 target: LOG_BITCOIND,
325 err = %err.fmt_compact(),
326 env = FM_BITCOIN_POLLING_INTERVAL_SECS_ENV,
327 "Could not parse env variable"
328 );
329 }
330 }
331 };
332 if is_running_in_test_env() {
333 debug!(target: LOG_BITCOIND, "Running in devimint, using fast node polling");
336 Duration::from_millis(100)
337 } else {
338 Duration::from_secs(60)
339 }
340 }
341 tokio::time::interval(get_bitcoin_polling_period())
342}
343
344fn get_median(vals: &[u64]) -> Option<u64> {
346 if vals.is_empty() {
347 return None;
348 }
349 let len = vals.len();
350 let mid = len / 2;
351
352 if len % 2 == 0 {
353 Some((vals[mid - 1] + vals[mid]) / 2)
354 } else {
355 Some(vals[mid])
356 }
357}