fedimint_server_bitcoin_rpc/
esplora.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use anyhow::Context;
5use bitcoin::{BlockHash, Transaction};
6use fedimint_core::envs::BitcoinRpcConfig;
7use fedimint_core::util::{FmtCompact, SafeUrl};
8use fedimint_core::{ChainId, Feerate};
9use fedimint_logging::{LOG_BITCOIND_ESPLORA, LOG_SERVER};
10use fedimint_server_core::bitcoin_rpc::IServerBitcoinRpc;
11use tracing::info;
12
13#[derive(Debug)]
14pub struct EsploraClient {
15    client: esplora_client::AsyncClient,
16    url: SafeUrl,
17    cached_chain_id: OnceLock<ChainId>,
18}
19
20impl EsploraClient {
21    pub fn new(url: &SafeUrl) -> anyhow::Result<Self> {
22        info!(
23            target: LOG_SERVER,
24            %url,
25            "Initializing bitcoin esplora backend"
26        );
27        // URL needs to have any trailing path including '/' removed
28        let without_trailing = url.as_str().trim_end_matches('/');
29
30        let builder = esplora_client::Builder::new(without_trailing);
31        let client = builder.build_async()?;
32        Ok(Self {
33            client,
34            url: url.clone(),
35            cached_chain_id: OnceLock::new(),
36        })
37    }
38}
39
40#[async_trait::async_trait]
41impl IServerBitcoinRpc for EsploraClient {
42    fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig {
43        BitcoinRpcConfig {
44            kind: "esplora".to_string(),
45            url: self.url.clone(),
46        }
47    }
48
49    fn get_url(&self) -> SafeUrl {
50        self.url.clone()
51    }
52
53    async fn get_block_count(&self) -> anyhow::Result<u64> {
54        match self.client.get_height().await {
55            Ok(height) => Ok(u64::from(height) + 1),
56            Err(e) => Err(e.into()),
57        }
58    }
59
60    async fn get_block_hash(&self, height: u64) -> anyhow::Result<BlockHash> {
61        Ok(self.client.get_block_hash(u32::try_from(height)?).await?)
62    }
63
64    async fn get_block(&self, block_hash: &BlockHash) -> anyhow::Result<bitcoin::Block> {
65        self.client
66            .get_block_by_hash(block_hash)
67            .await?
68            .context("Block with this hash is not available")
69    }
70
71    async fn get_feerate(&self) -> anyhow::Result<Option<Feerate>> {
72        let fee_estimates: HashMap<u16, f64> = self.client.get_fee_estimates().await?;
73
74        let fee_rate_vb = esplora_client::convert_fee_rate(1, fee_estimates).unwrap_or(1.0);
75
76        let fee_rate_kvb = fee_rate_vb * 1_000f32;
77
78        Ok(Some(Feerate {
79            sats_per_kvb: (fee_rate_kvb).ceil() as u64,
80        }))
81    }
82
83    async fn submit_transaction(&self, transaction: Transaction) {
84        let _ = self.client.broadcast(&transaction).await.map_err(|err| {
85            // `esplora-client` v0.6.0 only surfaces HTTP error codes, which prevents us
86            // from detecting errors for transactions already submitted.
87            // TODO: Suppress `esplora-client` already submitted errors when client is
88            // updated
89            // https://github.com/fedimint/fedimint/issues/3732
90            info!(target: LOG_BITCOIND_ESPLORA, err = %err.fmt_compact(), "Error broadcasting transaction");
91        });
92    }
93
94    async fn get_sync_progress(&self) -> anyhow::Result<Option<f64>> {
95        Ok(None)
96    }
97
98    async fn get_chain_id(&self) -> anyhow::Result<ChainId> {
99        if let Some(chain_id) = self.cached_chain_id.get() {
100            return Ok(*chain_id);
101        }
102
103        let chain_id = ChainId::new(self.get_block_hash(1).await?);
104        let _ = self.cached_chain_id.set(chain_id);
105        Ok(chain_id)
106    }
107}