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