Skip to main content

fedimint_server_bitcoin_rpc/
hybrid.rs

1#[cfg(test)]
2mod tests;
3
4use std::sync::OnceLock;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use anyhow::{Result, anyhow};
8use bitcoin::{BlockHash, Transaction, Txid};
9use fedimint_core::envs::BitcoinRpcConfig;
10use fedimint_core::util::{FmtCompact as _, SafeUrl};
11use fedimint_core::{ChainId, Feerate};
12use fedimint_logging::LOG_SERVER;
13use fedimint_server_core::bitcoin_rpc::{DynServerBitcoinRpc, IServerBitcoinRpc};
14use tracing::{info, warn};
15
16use crate::bitcoind::BitcoindClient;
17use crate::esplora::EsploraClient;
18
19/// A local bitcoind primary and trusted Esplora fallback on one chain.
20///
21/// Esplora is trusted for chain selection, including when bitcoind is
22/// unavailable. Construction does not probe or compare endpoint identities: a
23/// guardian's faulty backend consumes the federation's fault budget. See
24/// SECURITY.md, "Guardian Bitcoin Backends", for the limits of that redundancy.
25/// Reads remain bitcoind-first, except block counts use Esplora while Core
26/// explicitly reports initial block download. Broadcast remains primary-first.
27#[derive(Debug)]
28pub struct BitcoindClientWithFallback {
29    /// Primary full-node RPC.
30    bitcoind_client: DynServerBitcoinRpc,
31    /// Trusted fallback RPC.
32    esplora_client: DynServerBitcoinRpc,
33    /// First chain identity obtained during ordinary status reads.
34    chain_id: OnceLock<ChainId>,
35    /// Whether Core has reported completion of initial block download.
36    bitcoind_ibd_complete: AtomicBool,
37}
38
39impl BitcoindClientWithFallback {
40    /// Construct a hybrid backend using two trusted endpoints without probing
41    /// them.
42    pub async fn new(
43        username: String,
44        password: String,
45        bitcoind_url: &SafeUrl,
46        esplora_url: &SafeUrl,
47    ) -> Result<Self> {
48        info!(
49            target: LOG_SERVER,
50            %bitcoind_url,
51            %esplora_url,
52            "Initializing bitcoin bitcoind backend with trusted esplora fallback"
53        );
54        Ok(Self::from_clients(
55            BitcoindClient::new(username, password, bitcoind_url)?.into_dyn(),
56            EsploraClient::new(esplora_url)?.into_dyn(),
57        ))
58    }
59
60    /// Build the backend without contacting either endpoint.
61    fn from_clients(
62        bitcoind_client: DynServerBitcoinRpc,
63        esplora_client: DynServerBitcoinRpc,
64    ) -> Self {
65        Self {
66            bitcoind_client,
67            esplora_client,
68            chain_id: OnceLock::new(),
69            bitcoind_ibd_complete: AtomicBool::new(false),
70        }
71    }
72
73    async fn fallback_block_count(&self, primary: anyhow::Error) -> Result<u64> {
74        warn!(
75            target: LOG_SERVER,
76            error = %primary.fmt_compact(),
77            "Bitcoind block count unavailable; falling back to Esplora"
78        );
79        match self.esplora_client.get_block_count().await {
80            Ok(count) => Ok(count),
81            Err(_) => {
82                warn!(
83                    target: LOG_SERVER,
84                    "Esplora block-count fallback also failed; returning the bitcoind error"
85                );
86                Err(primary)
87            }
88        }
89    }
90}
91
92/// Try an ordinary read locally, then retry only that request on Esplora.
93macro_rules! read_rpc {
94    ($self:ident, $method:ident $(, $arg:expr)*) => {{
95        let primary = $self.bitcoind_client.$method($($arg),*).await;
96        match primary {
97            Ok(value) => Ok(value),
98            Err(primary) => {
99                warn!(
100                    target: LOG_SERVER,
101                    method = stringify!($method),
102                    error = %primary.fmt_compact(),
103                    "Bitcoind read failed; trying Esplora"
104                );
105                match $self.esplora_client.$method($($arg),*).await {
106                    Ok(value) => Ok(value),
107                    Err(_) => {
108                        warn!(
109                            target: LOG_SERVER,
110                            method = stringify!($method),
111                            "Esplora read fallback also failed; returning the bitcoind error"
112                        );
113                        Err(primary)
114                    }
115                }
116            }
117        }
118    }};
119}
120
121#[async_trait::async_trait]
122impl IServerBitcoinRpc for BitcoindClientWithFallback {
123    fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig {
124        self.bitcoind_client.get_bitcoin_rpc_config()
125    }
126
127    fn get_url(&self) -> SafeUrl {
128        self.bitcoind_client.get_url()
129    }
130
131    async fn get_block_count(&self) -> Result<u64> {
132        if self.bitcoind_ibd_complete.load(Ordering::Relaxed) {
133            return match self.bitcoind_client.get_block_count().await {
134                Ok(count) => Ok(count),
135                Err(primary) => self.fallback_block_count(primary).await,
136            };
137        }
138
139        match self
140            .bitcoind_client
141            .get_block_count_and_initial_block_download()
142            .await
143        {
144            Ok((_count, true)) => {
145                self.fallback_block_count(anyhow!("Bitcoind is in initial block download"))
146                    .await
147            }
148            Ok((count, false)) => {
149                self.bitcoind_ibd_complete.store(true, Ordering::Relaxed);
150                Ok(count)
151            }
152            Err(primary) => self.fallback_block_count(primary).await,
153        }
154    }
155
156    async fn get_block_hash(&self, height: u64) -> Result<BlockHash> {
157        read_rpc!(self, get_block_hash, height)
158    }
159
160    async fn get_block(&self, block_hash: &BlockHash) -> Result<bitcoin::Block> {
161        read_rpc!(self, get_block, block_hash)
162    }
163
164    async fn get_feerate(&self) -> Result<Option<Feerate>> {
165        match self.bitcoind_client.get_feerate().await {
166            Ok(Some(feerate)) => Ok(Some(feerate)),
167            Ok(None) => {
168                warn!(
169                    target: LOG_SERVER,
170                    "Bitcoind fee estimate unavailable; trying Esplora"
171                );
172                match self.esplora_client.get_feerate().await {
173                    Ok(feerate) => Ok(feerate),
174                    Err(_) => {
175                        warn!(
176                            target: LOG_SERVER,
177                            "Esplora fee-estimate fallback failed; retaining the unavailable bitcoind estimate"
178                        );
179                        Ok(None)
180                    }
181                }
182            }
183            Err(primary) => {
184                warn!(
185                    target: LOG_SERVER,
186                    error = %primary.fmt_compact(),
187                    "Bitcoind fee-estimate request failed; trying Esplora"
188                );
189                match self.esplora_client.get_feerate().await {
190                    Ok(feerate) => Ok(feerate),
191                    Err(_) => {
192                        warn!(
193                            target: LOG_SERVER,
194                            "Esplora fee-estimate fallback also failed; returning the bitcoind error"
195                        );
196                        Err(primary)
197                    }
198                }
199            }
200        }
201    }
202
203    async fn submit_transaction(&self, transaction: Transaction) -> Result<()> {
204        match self
205            .bitcoind_client
206            .submit_transaction(transaction.clone())
207            .await
208        {
209            Ok(()) => Ok(()),
210            Err(primary) => {
211                warn!(target: LOG_SERVER, error = %primary.fmt_compact(), "Bitcoind broadcast failed; trying Esplora");
212                match self.esplora_client.submit_transaction(transaction).await {
213                    Ok(()) => Ok(()),
214                    Err(_) => {
215                        warn!(
216                            target: LOG_SERVER,
217                            "Esplora broadcast fallback also failed; returning the bitcoind error"
218                        );
219                        Err(primary)
220                    }
221                }
222            }
223        }
224    }
225
226    async fn get_sync_progress(&self) -> Result<Option<f64>> {
227        Ok(None)
228    }
229
230    // Mempool reads have no esplora fallback: esplora cannot serve them at all,
231    // so a failing bitcoind means no mempool visibility rather than a degraded
232    // one. Callers treat the error the same way they treat `None`.
233
234    async fn get_mempool_txids(&self) -> Result<Option<Vec<Txid>>> {
235        self.bitcoind_client.get_mempool_txids().await
236    }
237
238    async fn get_mempool_tx(&self, txid: &Txid) -> Result<Option<Transaction>> {
239        self.bitcoind_client.get_mempool_tx(txid).await
240    }
241
242    async fn get_chain_id(&self) -> Result<ChainId> {
243        if let Some(chain_id) = self.chain_id.get() {
244            return Ok(*chain_id);
245        }
246
247        let chain_id = match self.bitcoind_client.get_chain_id().await {
248            Ok(chain_id) => chain_id,
249            Err(primary) => {
250                warn!(
251                    target: LOG_SERVER,
252                    error = %primary.fmt_compact(),
253                    "Bitcoind chain identity unavailable; trying Esplora"
254                );
255                self.esplora_client.get_chain_id().await?
256            }
257        };
258        let _ = self.chain_id.set(chain_id);
259        Ok(*self
260            .chain_id
261            .get()
262            .expect("chain identity was just initialized"))
263    }
264}