fedimint_server_bitcoin_rpc/
hybrid.rs1#[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#[derive(Debug)]
27pub struct BitcoindClientWithFallback {
28 bitcoind_client: DynServerBitcoinRpc,
30 esplora_client: DynServerBitcoinRpc,
32 chain_id: OnceLock<ChainId>,
34 bitcoind_ibd_complete: AtomicBool,
36}
37
38impl BitcoindClientWithFallback {
39 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 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
128macro_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}