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
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
38pub mod shared;
39
40// <https://blockstream.info/api/block-height/0>
41const MAINNET_GENESIS_BLOCK_HASH: &str =
42    "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";
43// <https://blockstream.info/testnet/api/block-height/0>
44const TESTNET_GENESIS_BLOCK_HASH: &str =
45    "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943";
46// <https://mempool.space/signet/api/block-height/0>
47const SIGNET_GENESIS_BLOCK_HASH: &str =
48    "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6";
49// See <https://bitcoin.stackexchange.com/questions/122778/is-the-regtest-genesis-hash-always-the-same-or-not>
50// <https://github.com/bitcoin/bitcoin/blob/d82283950f5ff3b2116e705f931c6e89e5fdd0be/src/kernel/chainparams.cpp#L478>
51const REGTEST_GENESIS_BLOCK_HASH: &str =
52    "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206";
53
54/// Global factories for creating bitcoin RPCs
55static BITCOIN_RPC_REGISTRY: LazyLock<Mutex<BTreeMap<String, DynBitcoindRpcFactory>>> =
56    LazyLock::new(|| {
57        Mutex::new(BTreeMap::from([
58            #[cfg(feature = "esplora-client")]
59            ("esplora".to_string(), esplora::EsploraFactory.into()),
60            #[cfg(feature = "bitcoincore-rpc")]
61            ("bitcoind".to_string(), bitcoincore::BitcoindFactory.into()),
62        ]))
63    });
64
65/// Create a bitcoin RPC of a given kind
66pub fn create_bitcoind(config: &BitcoinRpcConfig) -> Result<DynBitcoindRpc> {
67    let registry = BITCOIN_RPC_REGISTRY.lock().expect("lock poisoned");
68
69    let kind = env::var(FM_FORCE_BITCOIN_RPC_KIND_ENV)
70        .ok()
71        .unwrap_or_else(|| config.kind.clone());
72    let url = env::var(FM_FORCE_BITCOIN_RPC_URL_ENV)
73        .ok()
74        .map(|s| SafeUrl::parse(&s))
75        .transpose()?
76        .unwrap_or_else(|| config.url.clone());
77    debug!(target: LOG_CORE, %kind, %url, "Starting bitcoin rpc");
78    let maybe_factory = registry.get(&kind);
79    let factory = maybe_factory.with_context(|| {
80        anyhow::anyhow!(
81            "{} rpc not registered, available options: {:?}",
82            config.kind,
83            registry.keys()
84        )
85    })?;
86    factory.create_connection(&url)
87}
88
89/// Register a new factory for creating bitcoin RPCs
90pub fn register_bitcoind(kind: String, factory: DynBitcoindRpcFactory) {
91    let mut registry = BITCOIN_RPC_REGISTRY.lock().expect("lock poisoned");
92    registry.insert(kind, factory);
93}
94
95/// Trait for creating new bitcoin RPC clients
96pub trait IBitcoindRpcFactory: Debug + Send + Sync {
97    /// Creates a new bitcoin RPC client connection
98    fn create_connection(&self, url: &SafeUrl) -> Result<DynBitcoindRpc>;
99}
100
101dyn_newtype_define! {
102    #[derive(Clone)]
103    pub DynBitcoindRpcFactory(Arc<IBitcoindRpcFactory>)
104}
105
106/// Trait that allows interacting with the Bitcoin blockchain
107///
108/// Functions may panic if the bitcoind node is not reachable.
109#[apply(async_trait_maybe_send!)]
110pub trait IBitcoindRpc: Debug {
111    /// Returns the Bitcoin network the node is connected to
112    async fn get_network(&self) -> Result<bitcoin::Network>;
113
114    /// Returns the current block count
115    async fn get_block_count(&self) -> Result<u64>;
116
117    /// Returns the block hash at a given height
118    ///
119    /// # Panics
120    /// If the node does not know a block for that height. Make sure to only
121    /// query blocks of a height less to the one returned by
122    /// `Self::get_block_count`.
123    ///
124    /// While there is a corner case that the blockchain shrinks between these
125    /// two calls (through on average heavier blocks on a fork) this is
126    /// prevented by only querying hashes for blocks tailing the chain tip
127    /// by a certain number of blocks.
128    async fn get_block_hash(&self, height: u64) -> Result<BlockHash>;
129
130    async fn get_block(&self, block_hash: &BlockHash) -> Result<Block>;
131
132    /// Estimates the fee rate for a given confirmation target. Make sure that
133    /// all federation members use the same algorithm to avoid widely
134    /// diverging results. If the node is not ready yet to return a fee rate
135    /// estimation this function returns `None`.
136    async fn get_fee_rate(&self, confirmation_target: u16) -> Result<Option<Feerate>>;
137
138    /// Submits a transaction to the Bitcoin network
139    ///
140    /// This operation does not return anything as it never OK to consider its
141    /// success as final anyway. The caller should be retrying
142    /// broadcast periodically until it confirms the transaction was actually
143    /// via other means or decides that is no longer relevant.
144    ///
145    /// Also - most backends considers brodcasting a tx that is already included
146    /// in the blockchain as an error, which breaks idempotency and requires
147    /// brittle workarounds just to reliably ignore... just to retry on the
148    /// higher level anyway.
149    ///
150    /// Implementations of this error should log errors for debugging purposes
151    /// when it makes sense.
152    async fn submit_transaction(&self, transaction: Transaction);
153
154    /// If a transaction is included in a block, returns the block height.
155    /// Note: calling this method with bitcoind as a backend must first call
156    /// `watch_script_history` or run bitcoind with txindex enabled.
157    async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>>;
158
159    /// Check if a transaction is included in a block
160    async fn is_tx_in_block(
161        &self,
162        txid: &Txid,
163        block_hash: &BlockHash,
164        block_height: u64,
165    ) -> Result<bool>;
166
167    /// Watches for a script and returns any transactions associated with it
168    ///
169    /// Should be called at least prior to transactions being submitted or
170    /// watching may not occur on backends that need it
171    /// TODO: bitcoind backend is broken
172    /// `<https://github.com/fedimint/fedimint/issues/5329>`
173    async fn watch_script_history(&self, script: &ScriptBuf) -> Result<()>;
174
175    /// Get script transaction history
176    ///
177    /// Note: should call `watch_script_history` at least once, before calling
178    /// this.
179    async fn get_script_history(&self, script: &ScriptBuf) -> Result<Vec<Transaction>>;
180
181    /// Returns a proof that a tx is included in the bitcoin blockchain
182    async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof>;
183
184    /// Returns the node's estimated chain sync percentage as a float between
185    /// 0.0 and 1.0, or `None` if the node doesn't support this feature.
186    async fn get_sync_percentage(&self) -> Result<Option<f64>>;
187
188    /// Returns the Bitcoin RPC config
189    fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig;
190}
191
192dyn_newtype_define! {
193    #[derive(Clone)]
194    pub DynBitcoindRpc(Arc<IBitcoindRpc>)
195}
196
197impl DynBitcoindRpc {
198    /// Spawns a background task that queries the block count
199    /// periodically and sends over the returned channel.
200    pub fn spawn_block_count_update_task(
201        self,
202        task_group: &TaskGroup,
203        on_update: impl Fn(u64) + Send + Sync + 'static,
204    ) {
205        let mut desired_interval = get_bitcoin_polling_interval();
206
207        // Note: atomic only to workaround Send+Sync async closure limitation
208        let last_block_count = AtomicU64::new(0);
209
210        task_group.spawn_cancellable("block count background task", {
211            async move {
212                trace!(target: LOG_BITCOIND, "Fetching block count from bitcoind");
213
214                let update_block_count = || async {
215                    let res = self
216                        .get_block_count()
217                        .await;
218
219                    match res {
220                        Ok(block_count) => {
221                            if last_block_count.load(Ordering::SeqCst) != block_count {
222                                on_update(block_count);
223                                last_block_count.store(block_count, Ordering::SeqCst);
224                            }
225                        },
226                        Err(err) => {
227                            warn!(target: LOG_BITCOIND, err = %err.fmt_compact_anyhow(), "Unable to get block count from the node");
228                        }
229                    }
230                };
231
232                loop {
233                    let start = now();
234                    update_block_count().await;
235                    let duration = now().duration_since(start).unwrap_or_default();
236                    if Duration::from_secs(10) < duration {
237                        warn!(target: LOG_BITCOIND, duration_secs=duration.as_secs(), "Updating block count from bitcoind slow");
238                    }
239                    desired_interval.tick().await;
240                }
241            }
242        });
243    }
244
245    /// Spawns a background task that queries the feerate periodically and sends
246    /// over the returned channel.
247    pub fn spawn_fee_rate_update_task(
248        self,
249        task_group: &TaskGroup,
250        network: Network,
251        confirmation_target: u16,
252        on_update: impl Fn(Feerate) + Send + Sync + 'static,
253    ) -> anyhow::Result<()> {
254        let sources = std::env::var(FM_WALLET_FEERATE_SOURCES_ENV)
255            .unwrap_or_else(|_| match network {
256                Network::Bitcoin => "https://mempool.space/api/v1/fees/recommended#.hourFee;https://blockstream.info/api/fee-estimates#.\"1\"".to_owned(),
257                _ => String::new(),
258            })
259            .split(';')
260            .filter(|s| !s.is_empty())
261            .map(|s| Ok(Box::new(FetchJson::from_str(s)?) as Box<dyn FeeRateSource>))
262            .chain(iter::once(Ok(
263                Box::new(self.clone()) as Box<dyn FeeRateSource>
264            )))
265            .collect::<anyhow::Result<Vec<Box<dyn FeeRateSource>>>>()?;
266        let feerates = Arc::new(std::sync::Mutex::new(vec![None; sources.len()]));
267
268        let mut desired_interval = get_bitcoin_polling_interval();
269
270        task_group.spawn_cancellable("feerate background task", async move {
271            trace!(target: LOG_BITCOIND, "Fetching feerate from sources");
272
273            // Note: atomic only to workaround Send+Sync async closure limitation
274            let last_feerate = AtomicU64::new(0);
275
276            let update_fee_rate = || async {
277                trace!(target: LOG_BITCOIND, "Updating bitcoin fee rate");
278
279                let feerates_new = futures::future::join_all(sources.iter().map(|s| async { (s.name(), s.fetch(confirmation_target).await) } )).await;
280
281                let mut feerates = feerates.lock().expect("lock poisoned");
282                for (i, (name, res)) in feerates_new.into_iter().enumerate() {
283                    match res {
284                        Ok(ok) => feerates[i] = Some(ok),
285                        Err(err) => {
286                            // Regtest node never returns fee rate, so no point spamming about it
287                            if !is_running_in_test_env() {
288                                warn!(target: LOG_BITCOIND, err = %err.fmt_compact_anyhow(), %name, "Error getting feerate from source");
289                            }
290                        },
291                    }
292                }
293
294                let mut available_feerates : Vec<_> = feerates.iter().filter_map(Clone::clone).map(|r| r.sats_per_kvb).collect();
295
296                available_feerates.sort_unstable();
297
298                if let Some(feerate) = get_median(&available_feerates) {
299                    if feerate != last_feerate.load(Ordering::SeqCst) {
300                        on_update(Feerate { sats_per_kvb: feerate });
301                        last_feerate.store(feerate, Ordering::SeqCst);
302                    }
303                } else {
304                    // During tests (regtest) we never get any real feerate, so no point spamming about it
305                    if !is_running_in_test_env() {
306                        warn!(target: LOG_BITCOIND, "Unable to calculate any fee rate");
307                    }
308                }
309            };
310
311            loop {
312                let start = now();
313                update_fee_rate().await;
314                let duration = now().duration_since(start).unwrap_or_default();
315                if Duration::from_secs(10) < duration {
316                    warn!(target: LOG_BITCOIND, duration_secs=duration.as_secs(), "Updating feerate from bitcoind slow");
317                }
318                desired_interval.tick().await;
319            }
320        });
321
322        Ok(())
323    }
324}
325
326fn get_bitcoin_polling_interval() -> Interval {
327    fn get_bitcoin_polling_period() -> Duration {
328        if let Ok(s) = env::var(FM_BITCOIN_POLLING_INTERVAL_SECS_ENV) {
329            use std::str::FromStr;
330            match u64::from_str(&s) {
331                Ok(secs) => return Duration::from_secs(secs),
332                Err(err) => {
333                    warn!(
334                        target: LOG_BITCOIND,
335                        err = %err.fmt_compact(),
336                        env = FM_BITCOIN_POLLING_INTERVAL_SECS_ENV,
337                        "Could not parse env variable"
338                    );
339                }
340            }
341        };
342        if is_running_in_test_env() {
343            // In devimint, the setup is blocked by detecting block height changes,
344            // and polling more often is not an issue.
345            debug!(target: LOG_BITCOIND, "Running in devimint, using fast node polling");
346            Duration::from_millis(100)
347        } else {
348            Duration::from_secs(60)
349        }
350    }
351    tokio::time::interval(get_bitcoin_polling_period())
352}