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