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};
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 startup without bitcoind.
22/// A one-time startup equality check catches misconfiguration when both
23/// endpoints are available; it is not SPV or reconnection verification.
24/// Reads remain bitcoind-first, except block counts use Esplora while Core
25/// explicitly reports initial block download. Broadcast remains primary-first.
26#[derive(Debug)]
27pub struct BitcoindClientWithFallback {
28    /// Primary full-node RPC.
29    bitcoind_client: DynServerBitcoinRpc,
30    /// Trusted fallback RPC.
31    esplora_client: DynServerBitcoinRpc,
32    /// First chain identity obtained during startup or ordinary status reads.
33    chain_id: OnceLock<ChainId>,
34    /// Whether Core has reported completion of initial block download.
35    bitcoind_ibd_complete: AtomicBool,
36}
37
38impl BitcoindClientWithFallback {
39    /// Construct and initialize a hybrid backend using two trusted endpoints.
40    pub async fn new(
41        username: String,
42        password: String,
43        bitcoind_url: &SafeUrl,
44        esplora_url: &SafeUrl,
45    ) -> Result<Self> {
46        info!(
47            target: LOG_SERVER,
48            %bitcoind_url,
49            %esplora_url,
50            "Initializing bitcoin bitcoind backend with trusted esplora fallback"
51        );
52        Self::from_clients(
53            BitcoindClient::new(username, password, bitcoind_url)?.into_dyn(),
54            EsploraClient::new(esplora_url)?.into_dyn(),
55        )
56        .await
57    }
58
59    /// Perform the one-time startup identity check and build the backend.
60    async fn from_clients(
61        bitcoind_client: DynServerBitcoinRpc,
62        esplora_client: DynServerBitcoinRpc,
63    ) -> Result<Self> {
64        let (primary, fallback) = tokio::join!(
65            bitcoind_client.get_chain_id(),
66            esplora_client.get_chain_id(),
67        );
68        let chain_id = match (primary, fallback) {
69            (Ok(primary), Ok(fallback)) => {
70                if primary != fallback {
71                    return Err(anyhow!("Bitcoind and Esplora chain identities differ"));
72                }
73                Some(primary)
74            }
75            (Ok(chain_id), Err(_)) => {
76                warn!(
77                    target: LOG_SERVER,
78                    "Could not compare Esplora chain identity at startup; using bitcoind identity"
79                );
80                Some(chain_id)
81            }
82            (Err(_), Ok(chain_id)) => {
83                warn!(
84                    target: LOG_SERVER,
85                    "Could not compare bitcoind chain identity at startup; using trusted Esplora identity"
86                );
87                Some(chain_id)
88            }
89            (Err(_), Err(_)) => {
90                warn!(
91                    target: LOG_SERVER,
92                    "Could not check either Bitcoin backend chain identity at startup"
93                );
94                None
95            }
96        };
97        let cached_chain_id = OnceLock::new();
98        if let Some(chain_id) = chain_id {
99            let _ = cached_chain_id.set(chain_id);
100        }
101        Ok(Self {
102            bitcoind_client,
103            esplora_client,
104            chain_id: cached_chain_id,
105            bitcoind_ibd_complete: AtomicBool::new(false),
106        })
107    }
108
109    async fn fallback_block_count(&self, primary: anyhow::Error) -> Result<u64> {
110        warn!(
111            target: LOG_SERVER,
112            error = %primary.fmt_compact(),
113            "Bitcoind block count unavailable; falling back to Esplora"
114        );
115        match self.esplora_client.get_block_count().await {
116            Ok(count) => Ok(count),
117            Err(_) => {
118                warn!(
119                    target: LOG_SERVER,
120                    "Esplora block-count fallback also failed; returning the bitcoind error"
121                );
122                Err(primary)
123            }
124        }
125    }
126}
127
128/// Try an ordinary read locally, then retry only that request on Esplora.
129macro_rules! read_rpc {
130    ($self:ident, $method:ident $(, $arg:expr)*) => {{
131        let primary = $self.bitcoind_client.$method($($arg),*).await;
132        match primary {
133            Ok(value) => Ok(value),
134            Err(primary) => {
135                warn!(
136                    target: LOG_SERVER,
137                    method = stringify!($method),
138                    error = %primary.fmt_compact(),
139                    "Bitcoind read failed; trying Esplora"
140                );
141                match $self.esplora_client.$method($($arg),*).await {
142                    Ok(value) => Ok(value),
143                    Err(_) => {
144                        warn!(
145                            target: LOG_SERVER,
146                            method = stringify!($method),
147                            "Esplora read fallback also failed; returning the bitcoind error"
148                        );
149                        Err(primary)
150                    }
151                }
152            }
153        }
154    }};
155}
156
157#[async_trait::async_trait]
158impl IServerBitcoinRpc for BitcoindClientWithFallback {
159    fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig {
160        self.bitcoind_client.get_bitcoin_rpc_config()
161    }
162
163    fn get_url(&self) -> SafeUrl {
164        self.bitcoind_client.get_url()
165    }
166
167    async fn get_block_count(&self) -> Result<u64> {
168        if self.bitcoind_ibd_complete.load(Ordering::Relaxed) {
169            return match self.bitcoind_client.get_block_count().await {
170                Ok(count) => Ok(count),
171                Err(primary) => self.fallback_block_count(primary).await,
172            };
173        }
174
175        match self
176            .bitcoind_client
177            .get_block_count_and_initial_block_download()
178            .await
179        {
180            Ok((_count, true)) => {
181                self.fallback_block_count(anyhow!("Bitcoind is in initial block download"))
182                    .await
183            }
184            Ok((count, false)) => {
185                self.bitcoind_ibd_complete.store(true, Ordering::Relaxed);
186                Ok(count)
187            }
188            Err(primary) => self.fallback_block_count(primary).await,
189        }
190    }
191
192    async fn get_block_hash(&self, height: u64) -> Result<BlockHash> {
193        read_rpc!(self, get_block_hash, height)
194    }
195
196    async fn get_block(&self, block_hash: &BlockHash) -> Result<bitcoin::Block> {
197        read_rpc!(self, get_block, block_hash)
198    }
199
200    async fn get_feerate(&self) -> Result<Option<Feerate>> {
201        match self.bitcoind_client.get_feerate().await {
202            Ok(Some(feerate)) => Ok(Some(feerate)),
203            Ok(None) => {
204                warn!(
205                    target: LOG_SERVER,
206                    "Bitcoind fee estimate unavailable; trying Esplora"
207                );
208                match self.esplora_client.get_feerate().await {
209                    Ok(feerate) => Ok(feerate),
210                    Err(_) => {
211                        warn!(
212                            target: LOG_SERVER,
213                            "Esplora fee-estimate fallback failed; retaining the unavailable bitcoind estimate"
214                        );
215                        Ok(None)
216                    }
217                }
218            }
219            Err(primary) => {
220                warn!(
221                    target: LOG_SERVER,
222                    error = %primary.fmt_compact(),
223                    "Bitcoind fee-estimate request failed; trying Esplora"
224                );
225                match self.esplora_client.get_feerate().await {
226                    Ok(feerate) => Ok(feerate),
227                    Err(_) => {
228                        warn!(
229                            target: LOG_SERVER,
230                            "Esplora fee-estimate fallback also failed; returning the bitcoind error"
231                        );
232                        Err(primary)
233                    }
234                }
235            }
236        }
237    }
238
239    async fn submit_transaction(&self, transaction: Transaction) -> Result<()> {
240        match self
241            .bitcoind_client
242            .submit_transaction(transaction.clone())
243            .await
244        {
245            Ok(()) => Ok(()),
246            Err(primary) => {
247                warn!(target: LOG_SERVER, error = %primary.fmt_compact(), "Bitcoind broadcast failed; trying Esplora");
248                match self.esplora_client.submit_transaction(transaction).await {
249                    Ok(()) => Ok(()),
250                    Err(_) => {
251                        warn!(
252                            target: LOG_SERVER,
253                            "Esplora broadcast fallback also failed; returning the bitcoind error"
254                        );
255                        Err(primary)
256                    }
257                }
258            }
259        }
260    }
261
262    async fn get_sync_progress(&self) -> Result<Option<f64>> {
263        Ok(None)
264    }
265
266    async fn get_chain_id(&self) -> Result<ChainId> {
267        if let Some(chain_id) = self.chain_id.get() {
268            return Ok(*chain_id);
269        }
270
271        let chain_id = match self.bitcoind_client.get_chain_id().await {
272            Ok(chain_id) => chain_id,
273            Err(primary) => {
274                warn!(
275                    target: LOG_SERVER,
276                    error = %primary.fmt_compact(),
277                    "Bitcoind chain identity unavailable; trying Esplora"
278                );
279                self.esplora_client.get_chain_id().await?
280            }
281        };
282        let _ = self.chain_id.set(chain_id);
283        Ok(*self
284            .chain_id
285            .get()
286            .expect("chain identity was just initialized"))
287    }
288}