fedimint_server_bitcoin_rpc/
esplora.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use anyhow::{Context, bail};
5use bitcoin::{BlockHash, Network, Transaction};
6use fedimint_core::Feerate;
7use fedimint_core::envs::BitcoinRpcConfig;
8use fedimint_core::util::{FmtCompact as _, SafeUrl};
9use fedimint_logging::{LOG_BITCOIND_ESPLORA, LOG_SERVER};
10use fedimint_server_core::bitcoin_rpc::IServerBitcoinRpc;
11use tracing::{debug, info};
12
13// <https://blockstream.info/api/block-height/0>
14const MAINNET: &str = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";
15
16// <https://blockstream.info/testnet/api/block-height/0>
17const TESTNET: &str = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943";
18
19// <https://mempool.space/signet/api/block-height/0>
20const SIGNET: &str = "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6";
21
22// See <https://bitcoin.stackexchange.com/questions/122778/is-the-regtest-genesis-hash-always-the-same-or-not>
23// <https://github.com/bitcoin/bitcoin/blob/d82283950f5ff3b2116e705f931c6e89e5fdd0be/src/kernel/chainparams.cpp#L478>
24const REGTEST: &str = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206";
25
26#[derive(Debug)]
27pub struct EsploraClient {
28    client: esplora_client::AsyncClient,
29    url: SafeUrl,
30    cached_network: OnceLock<Network>,
31}
32
33impl EsploraClient {
34    pub fn new(url: &SafeUrl) -> anyhow::Result<Self> {
35        info!(
36            target: LOG_SERVER,
37            %url,
38            "Initiallizing bitcoin esplora backend"
39        );
40        // URL needs to have any trailing path including '/' removed
41        let without_trailing = url.as_str().trim_end_matches('/');
42
43        let builder = esplora_client::Builder::new(without_trailing);
44        let client = builder.build_async()?;
45        Ok(Self {
46            client,
47            url: url.clone(),
48            cached_network: OnceLock::new(),
49        })
50    }
51}
52
53#[async_trait::async_trait]
54impl IServerBitcoinRpc for EsploraClient {
55    fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig {
56        BitcoinRpcConfig {
57            kind: "esplora".to_string(),
58            url: self.url.clone(),
59        }
60    }
61
62    fn get_url(&self) -> SafeUrl {
63        self.url.clone()
64    }
65
66    async fn get_network(&self) -> anyhow::Result<Network> {
67        // Return cached network if already fetched
68        if let Some(network) = self.cached_network.get() {
69            return Ok(*network);
70        }
71
72        // Fetch and cache the network
73        let genesis_hash = self.client.get_block_hash(0).await.inspect_err(|err| {
74            debug!(
75                target: LOG_BITCOIND_ESPLORA,
76                err = %err.fmt_compact(),
77                "Error getting network (genesis hash) from esplora backend");
78        })?;
79
80        let network = match genesis_hash.to_string().as_str() {
81            MAINNET => Network::Bitcoin,
82            TESTNET => Network::Testnet,
83            SIGNET => Network::Signet,
84            REGTEST => Network::Regtest,
85            hash => {
86                bail!("Unknown genesis hash {hash}");
87            }
88        };
89
90        // Cache the successful result
91        let _ = self.cached_network.set(network);
92        Ok(network)
93    }
94
95    async fn get_block_count(&self) -> anyhow::Result<u64> {
96        match self.client.get_height().await {
97            Ok(height) => Ok(u64::from(height) + 1),
98            Err(e) => Err(e.into()),
99        }
100    }
101
102    async fn get_block_hash(&self, height: u64) -> anyhow::Result<BlockHash> {
103        Ok(self.client.get_block_hash(u32::try_from(height)?).await?)
104    }
105
106    async fn get_block(&self, block_hash: &BlockHash) -> anyhow::Result<bitcoin::Block> {
107        self.client
108            .get_block_by_hash(block_hash)
109            .await?
110            .context("Block with this hash is not available")
111    }
112
113    async fn get_feerate(&self) -> anyhow::Result<Option<Feerate>> {
114        let fee_estimates: HashMap<u16, f64> = self.client.get_fee_estimates().await?;
115
116        let fee_rate_vb = esplora_client::convert_fee_rate(1, fee_estimates).unwrap_or(1.0);
117
118        let fee_rate_kvb = fee_rate_vb * 1_000f32;
119
120        Ok(Some(Feerate {
121            sats_per_kvb: (fee_rate_kvb).ceil() as u64,
122        }))
123    }
124
125    async fn submit_transaction(&self, transaction: Transaction) {
126        let _ = self.client.broadcast(&transaction).await.map_err(|error| {
127            // `esplora-client` v0.6.0 only surfaces HTTP error codes, which prevents us
128            // from detecting errors for transactions already submitted.
129            // TODO: Suppress `esplora-client` already submitted errors when client is
130            // updated
131            // https://github.com/fedimint/fedimint/issues/3732
132            info!(target: LOG_BITCOIND_ESPLORA, ?error, "Error broadcasting transaction");
133        });
134    }
135
136    async fn get_sync_percentage(&self) -> anyhow::Result<Option<f64>> {
137        Ok(None)
138    }
139}