Skip to main content

fedimint_server_core/
bitcoin_rpc.rs

1use std::fmt::Debug;
2use std::sync::{Arc, OnceLock};
3use std::time::Duration;
4
5use anyhow::{Result, ensure};
6use fedimint_core::bitcoin::{Block, BlockHash, Network, Transaction, Txid};
7use fedimint_core::envs::BitcoinRpcConfig;
8use fedimint_core::task::TaskGroup;
9use fedimint_core::util::{FmtCompact as _, SafeUrl};
10use fedimint_core::{ChainId, Feerate};
11use fedimint_logging::LOG_SERVER;
12use tokio::sync::watch;
13use tracing::{debug, warn};
14
15use crate::dashboard_ui::ServerBitcoinRpcStatus;
16
17// Well-known genesis block hashes for different Bitcoin networks
18// <https://blockstream.info/api/block-height/1>
19const MAINNET_CHAIN_ID_STR: &str =
20    "00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048";
21// <https://blockstream.info/testnet/api/block-height/1>
22const TESTNET_CHAIN_ID_STR: &str =
23    "00000000b873e79784647a6c82962c70d228557d24a747ea4d1b8bbe878e1206";
24// <https://mempool.space/signet/api/block-height/1>
25const SIGNET_4_CHAIN_ID_STR: &str =
26    "00000086d6b2636cb2a392d45edc4ec544a10024d30141c9adf4bfd9de533b53";
27// <https://mutinynet.com/api/block-height/1>
28const MUTINYNET_CHAIN_ID_STR: &str =
29    "000002855893a0a9b24eaffc5efc770558a326fee4fc10c9da22fc19cd2954f9";
30
31/// Derives the Bitcoin network from a chain ID (block height 1 block hash).
32///
33/// Returns the corresponding `Network` for well-known genesis hashes,
34/// or `Network::Regtest` for unknown hashes (custom/private networks).
35pub fn network_from_chain_id(chain_id: ChainId) -> Network {
36    match chain_id.to_string().as_str() {
37        MAINNET_CHAIN_ID_STR => Network::Bitcoin,
38        TESTNET_CHAIN_ID_STR => Network::Testnet,
39        SIGNET_4_CHAIN_ID_STR => Network::Signet,
40        MUTINYNET_CHAIN_ID_STR => Network::Signet,
41        _ => {
42            // Unknown genesis hash - treat as regtest/custom network
43            Network::Regtest
44        }
45    }
46}
47
48#[derive(Debug)]
49pub struct ServerBitcoinRpcMonitor {
50    rpc: DynServerBitcoinRpc,
51    status_receiver: watch::Receiver<Option<ServerBitcoinRpcStatus>>,
52    /// Cached chain ID (block hash at height 1) - fetched once and never
53    /// changes
54    chain_id: OnceLock<ChainId>,
55}
56
57impl ServerBitcoinRpcMonitor {
58    pub fn new(
59        rpc: DynServerBitcoinRpc,
60        update_interval: Duration,
61        task_group: &TaskGroup,
62    ) -> Self {
63        let (status_sender, status_receiver) = watch::channel(None);
64
65        let rpc_clone = rpc.clone();
66        debug!(
67            target: LOG_SERVER,
68            interval_ms  = %update_interval.as_millis(),
69            "Starting bitcoin rpc monitor"
70        );
71
72        task_group.spawn_cancellable("bitcoin-status-update", async move {
73            let mut interval = tokio::time::interval(update_interval);
74            loop {
75                interval.tick().await;
76                match Self::fetch_status(&rpc_clone).await {
77                    Ok(new_status) => {
78                        status_sender.send_replace(Some(new_status));
79                    }
80                    Err(err) => {
81                        warn!(
82                            target: LOG_SERVER,
83                            err = %err.fmt_compact(),
84                            "Bitcoin status update failed"
85                        );
86                        status_sender.send_replace(None);
87                    }
88                }
89            }
90        });
91
92        Self {
93            rpc,
94            status_receiver,
95            chain_id: OnceLock::new(),
96        }
97    }
98
99    async fn fetch_status(rpc: &DynServerBitcoinRpc) -> Result<ServerBitcoinRpcStatus> {
100        let chain_id = rpc.get_chain_id().await?;
101        let network = network_from_chain_id(chain_id);
102        let block_count = rpc.get_block_count().await?;
103        let sync_progress = rpc.get_sync_progress().await?;
104
105        let fee_rate = if network == Network::Regtest {
106            Some(Feerate { sats_per_kvb: 1000 })
107        } else {
108            rpc.get_feerate().await?
109        };
110
111        Ok(ServerBitcoinRpcStatus {
112            network,
113            block_count,
114            fee_rate,
115            sync_progress,
116        })
117    }
118
119    pub fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig {
120        self.rpc.get_bitcoin_rpc_config()
121    }
122
123    pub fn url(&self) -> SafeUrl {
124        self.rpc.get_url()
125    }
126
127    pub fn status(&self) -> Option<ServerBitcoinRpcStatus> {
128        self.status_receiver.borrow().clone()
129    }
130
131    /// Returns a receiver that can be awaited to be notified whenever the
132    /// status is refreshed.
133    ///
134    /// This lets a consumer react to the monitor's own polling cadence instead
135    /// of running an independent timer, which would otherwise read a status
136    /// that is already up to one update interval stale before sleeping again.
137    ///
138    /// Note the monitor republishes on every update interval regardless of
139    /// whether the status actually changed, so the receiver fires once per
140    /// interval rather than only on a real change.
141    pub fn subscribe_status(&self) -> watch::Receiver<Option<ServerBitcoinRpcStatus>> {
142        self.status_receiver.clone()
143    }
144
145    pub async fn get_block(&self, hash: &BlockHash) -> Result<Block> {
146        ensure!(
147            self.status_receiver.borrow().is_some(),
148            "Not connected to bitcoin backend"
149        );
150
151        self.rpc.get_block(hash).await
152    }
153
154    /// See [`IServerBitcoinRpc::get_mempool_txids`]; `None` means the backend
155    /// has no mempool visibility, not that the mempool is empty.
156    pub async fn get_mempool_txids(&self) -> Result<Option<Vec<Txid>>> {
157        ensure!(
158            self.status_receiver.borrow().is_some(),
159            "Not connected to bitcoin backend"
160        );
161
162        self.rpc.get_mempool_txids().await
163    }
164
165    /// See [`IServerBitcoinRpc::get_mempool_tx`].
166    pub async fn get_mempool_tx(&self, txid: &Txid) -> Result<Option<Transaction>> {
167        ensure!(
168            self.status_receiver.borrow().is_some(),
169            "Not connected to bitcoin backend"
170        );
171
172        self.rpc.get_mempool_tx(txid).await
173    }
174
175    pub async fn get_block_hash(&self, height: u64) -> Result<BlockHash> {
176        ensure!(
177            self.status_receiver.borrow().is_some(),
178            "Not connected to bitcoin backend"
179        );
180
181        self.rpc.get_block_hash(height).await
182    }
183
184    pub async fn submit_transaction(&self, tx: Transaction) -> Result<()> {
185        self.rpc.submit_transaction(tx).await
186    }
187
188    /// Returns the chain ID, caching the result after the
189    /// first successful fetch.
190    pub async fn get_chain_id(&self) -> Result<ChainId> {
191        // Return cached value if available
192        if let Some(chain_id) = self.chain_id.get() {
193            return Ok(*chain_id);
194        }
195
196        ensure!(
197            self.status_receiver.borrow().is_some(),
198            "Not connected to bitcoin backend"
199        );
200
201        // Fetch from RPC and cache
202        let chain_id = self.rpc.get_chain_id().await?;
203        // It's OK if another task already set the value - the chain ID is immutable
204        let _ = self.chain_id.set(chain_id);
205
206        Ok(chain_id)
207    }
208}
209
210impl Clone for ServerBitcoinRpcMonitor {
211    fn clone(&self) -> Self {
212        Self {
213            rpc: self.rpc.clone(),
214            status_receiver: self.status_receiver.clone(),
215            chain_id: self
216                .chain_id
217                .get()
218                .copied()
219                .map(|h| {
220                    let lock = OnceLock::new();
221                    let _ = lock.set(h);
222                    lock
223                })
224                .unwrap_or_default(),
225        }
226    }
227}
228
229pub type DynServerBitcoinRpc = Arc<dyn IServerBitcoinRpc>;
230
231#[async_trait::async_trait]
232pub trait IServerBitcoinRpc: Debug + Send + Sync + 'static {
233    /// Returns the Bitcoin RPC config
234    fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig;
235
236    /// Returns the Bitcoin RPC url
237    fn get_url(&self) -> SafeUrl;
238
239    /// Returns the current block count
240    async fn get_block_count(&self) -> Result<u64>;
241
242    /// Returns the current block count and whether initial block download is
243    /// still active.
244    ///
245    /// Backends without an explicit IBD flag are assumed ready. Full nodes
246    /// should override this method when both values come from one status call.
247    async fn get_block_count_and_initial_block_download(&self) -> Result<(u64, bool)> {
248        Ok((self.get_block_count().await?, false))
249    }
250
251    /// Returns the block hash at a given height
252    ///
253    /// # Panics
254    /// If the node does not know a block for that height. Make sure to only
255    /// query blocks of a height less to the one returned by
256    /// `Self::get_block_count`.
257    ///
258    /// While there is a corner case that the blockchain shrinks between these
259    /// two calls (through on average heavier blocks on a fork) this is
260    /// prevented by only querying hashes for blocks tailing the chain tip
261    /// by a certain number of blocks.
262    async fn get_block_hash(&self, height: u64) -> Result<BlockHash>;
263
264    async fn get_block(&self, block_hash: &BlockHash) -> Result<Block>;
265
266    /// Estimates the fee rate for a given confirmation target. Make sure that
267    /// all federation members use the same algorithm to avoid widely
268    /// diverging results. If the node is not ready yet to return a fee rate
269    /// estimation this function returns `None`.
270    async fn get_feerate(&self) -> Result<Option<Feerate>>;
271
272    /// Submits a transaction to the Bitcoin network
273    ///
274    /// It is never OK to consider the success of this call as final, as the
275    /// caller should be retrying broadcast periodically until it confirms the
276    /// transaction was actually accepted via other means or decides that it is
277    /// no longer relevant. The returned `Result` is therefore purely
278    /// informational and callers are expected to log and ignore any error.
279    ///
280    /// Also - most backends considers brodcasting a tx that is already included
281    /// in the blockchain as an error, which breaks idempotency and requires
282    /// brittle workarounds just to reliably ignore... just to retry on the
283    /// higher level anyway.
284    async fn submit_transaction(&self, transaction: Transaction) -> Result<()>;
285
286    /// Returns the node's estimated chain sync percentage as a float between
287    /// 0.0 and 1.0, or `None` if the node doesn't support this feature.
288    async fn get_sync_progress(&self) -> Result<Option<f64>>;
289
290    /// Returns the ids of every transaction in the node's mempool, or `None`
291    /// if the backend cannot enumerate it.
292    ///
293    /// Enumerating the mempool is only possible on backends that expose the
294    /// whole of it. Esplora deliberately returns `None`: its mempool APIs are
295    /// per-address, and resolving a full mempool through them would take one
296    /// request per transaction.
297    ///
298    /// Callers must treat `None` as "this backend has no mempool visibility",
299    /// never as "the mempool is empty".
300    async fn get_mempool_txids(&self) -> Result<Option<Vec<Txid>>> {
301        Ok(None)
302    }
303
304    /// Returns a transaction from the node's mempool.
305    ///
306    /// `None` means either that the backend cannot serve mempool transactions
307    /// or that this transaction is no longer in the mempool, which is expected
308    /// when it is mined or evicted between listing and fetching it.
309    async fn get_mempool_tx(&self, txid: &Txid) -> Result<Option<Transaction>> {
310        let _ = txid;
311
312        Ok(None)
313    }
314
315    /// Returns the chain ID (block hash at height 1)
316    ///
317    /// The chain ID uniquely identifies which Bitcoin network this node is
318    /// connected to. Use [`network_from_chain_id`] to derive the `Network`
319    /// enum from the chain ID.
320    async fn get_chain_id(&self) -> Result<ChainId>;
321
322    fn into_dyn(self) -> DynServerBitcoinRpc
323    where
324        Self: Sized,
325    {
326        Arc::new(self)
327    }
328}