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
38#[cfg(feature = "fedimint-server")]
39pub mod shared;
40
41// <https://blockstream.info/api/block-height/0>
42const MAINNET_GENESIS_BLOCK_HASH: &str =
43    "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";
44// <https://blockstream.info/testnet/api/block-height/0>
45const TESTNET_GENESIS_BLOCK_HASH: &str =
46    "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943";
47// <https://mempool.space/signet/api/block-height/0>
48const SIGNET_GENESIS_BLOCK_HASH: &str =
49    "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6";
50// See <https://bitcoin.stackexchange.com/questions/122778/is-the-regtest-genesis-hash-always-the-same-or-not>
51// <https://github.com/bitcoin/bitcoin/blob/d82283950f5ff3b2116e705f931c6e89e5fdd0be/src/kernel/chainparams.cpp#L478>
52const REGTEST_GENESIS_BLOCK_HASH: &str =
53    "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206";
54
55/// Global factories for creating bitcoin RPCs
56static 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
66/// Create a bitcoin RPC of a given kind
67pub 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
90/// Register a new factory for creating bitcoin RPCs
91pub 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
96/// Trait for creating new bitcoin RPC clients
97pub trait IBitcoindRpcFactory: Debug + Send + Sync {
98    /// Creates a new bitcoin RPC client connection
99    fn create_connection(&self, url: &SafeUrl) -> Result<DynBitcoindRpc>;
100}
101
102dyn_newtype_define! {
103    #[derive(Clone)]
104    pub DynBitcoindRpcFactory(Arc<IBitcoindRpcFactory>)
105}
106
107/// Trait that allows interacting with the Bitcoin blockchain
108///
109/// Functions may panic if the bitcoind node is not reachable.
110#[apply(async_trait_maybe_send!)]
111pub trait IBitcoindRpc: Debug {
112    /// Returns the Bitcoin network the node is connected to
113    async fn get_network(&self) -> Result<bitcoin::Network>;
114
115    /// Returns the current block count
116    async fn get_block_count(&self) -> Result<u64>;
117
118    /// Returns the block hash at a given height
119    ///
120    /// # Panics
121    /// If the node does not know a block for that height. Make sure to only
122    /// query blocks of a height less to the one returned by
123    /// `Self::get_block_count`.
124    ///
125    /// While there is a corner case that the blockchain shrinks between these
126    /// two calls (through on average heavier blocks on a fork) this is
127    /// prevented by only querying hashes for blocks tailing the chain tip
128    /// by a certain number of blocks.
129    async fn get_block_hash(&self, height: u64) -> Result<BlockHash>;
130
131    async fn get_block(&self, block_hash: &BlockHash) -> Result<Block>;
132
133    /// Estimates the fee rate for a given confirmation target. Make sure that
134    /// all federation members use the same algorithm to avoid widely
135    /// diverging results. If the node is not ready yet to return a fee rate
136    /// estimation this function returns `None`.
137    async fn get_fee_rate(&self, confirmation_target: u16) -> Result<Option<Feerate>>;
138
139    /// Submits a transaction to the Bitcoin network
140    ///
141    /// This operation does not return anything as it never OK to consider its
142    /// success as final anyway. The caller should be retrying
143    /// broadcast periodically until it confirms the transaction was actually
144    /// via other means or decides that is no longer relevant.
145    ///
146    /// Also - most backends considers brodcasting a tx that is already included
147    /// in the blockchain as an error, which breaks idempotency and requires
148    /// brittle workarounds just to reliably ignore... just to retry on the
149    /// higher level anyway.
150    ///
151    /// Implementations of this error should log errors for debugging purposes
152    /// when it makes sense.
153    async fn submit_transaction(&self, transaction: Transaction);
154
155    /// If a transaction is included in a block, returns the block height.
156    /// Note: calling this method with bitcoind as a backend must first call
157    /// `watch_script_history` or run bitcoind with txindex enabled.
158    async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>>;
159
160    /// Check if a transaction is included in a block
161    async fn is_tx_in_block(
162        &self,
163        txid: &Txid,
164        block_hash: &BlockHash,
165        block_height: u64,
166    ) -> Result<bool>;
167
168    /// Watches for a script and returns any transactions associated with it
169    ///
170    /// Should be called at least prior to transactions being submitted or
171    /// watching may not occur on backends that need it
172    /// TODO: bitcoind backend is broken
173    /// `<https://github.com/fedimint/fedimint/issues/5329>`
174    async fn watch_script_history(&self, script: &ScriptBuf) -> Result<()>;
175
176    /// Get script transaction history
177    ///
178    /// Note: should call `watch_script_history` at least once, before calling
179    /// this.
180    async fn get_script_history(&self, script: &ScriptBuf) -> Result<Vec<Transaction>>;
181
182    /// Returns a proof that a tx is included in the bitcoin blockchain
183    async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof>;
184
185    /// Returns the node's estimated chain sync percentage as a float between
186    /// 0.0 and 1.0, or `None` if the node doesn't support this feature.
187    async fn get_sync_percentage(&self) -> Result<Option<f64>>;
188
189    /// Returns the Bitcoin RPC config
190    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    /// Spawns a background task that queries the block count
200    /// periodically and sends over the returned channel.
201    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        // Note: atomic only to workaround Send+Sync async closure limitation
209        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    /// Spawns a background task that queries the feerate periodically and sends
247    /// over the returned channel.
248    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            // Note: atomic only to workaround Send+Sync async closure limitation
275            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                            // Regtest node never returns fee rate, so no point spamming about it
288                            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                    // During tests (regtest) we never get any real feerate, so no point spamming about it
306                    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            // In devimint, the setup is blocked by detecting block height changes,
345            // and polling more often is not an issue.
346            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}