Skip to main content

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