Skip to main content

fedimint_server_bitcoin_rpc/
esplora.rs

1#[cfg(test)]
2mod tests;
3
4use std::collections::HashSet;
5use std::sync::OnceLock;
6use std::time::Duration;
7
8use anyhow::{Context, ensure};
9use bitcoin::{BlockHash, Transaction};
10use fedimint_core::envs::BitcoinRpcConfig;
11use fedimint_core::util::SafeUrl;
12use fedimint_core::{ChainId, Feerate};
13use fedimint_logging::LOG_SERVER;
14use fedimint_server_core::bitcoin_rpc::IServerBitcoinRpc;
15use tracing::info;
16
17const ESPLORA_CLIENT_TIMEOUT_SECONDS: u64 = 60;
18
19/// Check payload integrity against the caller's requested header hash.
20///
21/// This does not validate chain selection or proof of work: the configured
22/// Esplora server remains a trusted source of block hashes.
23fn validate_block(block: bitcoin::Block, requested: &BlockHash) -> anyhow::Result<bitcoin::Block> {
24    ensure!(
25        block.block_hash() == *requested,
26        "Esplora returned a different block"
27    );
28    ensure!(
29        block.check_merkle_root(),
30        "Esplora returned an invalid transaction merkle root"
31    );
32    // Merkle roots alone permit mutation by duplicating the final subtree.
33    // A valid block cannot repeat a txid, so reject all duplicate transactions.
34    let mut txids = HashSet::with_capacity(block.txdata.len());
35    ensure!(
36        block
37            .txdata
38            .iter()
39            .all(|tx| txids.insert(tx.compute_txid())),
40        "Esplora returned duplicate transactions"
41    );
42    Ok(block)
43}
44
45pub struct EsploraClient {
46    client: esplora_client::AsyncClient,
47    url: SafeUrl,
48    cached_chain_id: OnceLock<ChainId>,
49}
50
51impl std::fmt::Debug for EsploraClient {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("EsploraClient")
54            .field("url", &self.url)
55            .field("cached_chain_id", &self.cached_chain_id)
56            .finish_non_exhaustive()
57    }
58}
59
60impl EsploraClient {
61    pub fn new(url: &SafeUrl) -> anyhow::Result<Self> {
62        info!(
63            target: LOG_SERVER,
64            %url,
65            "Initializing bitcoin esplora backend"
66        );
67        // URL needs to have any trailing path including '/' removed
68        let without_trailing = url.as_str().trim_end_matches('/');
69
70        let builder = esplora_client::Builder::new(without_trailing)
71            .timeout(Duration::from_secs(ESPLORA_CLIENT_TIMEOUT_SECONDS));
72        let client = builder.build_async()?;
73        Ok(Self {
74            client,
75            url: url.clone(),
76            cached_chain_id: OnceLock::new(),
77        })
78    }
79}
80
81#[async_trait::async_trait]
82impl IServerBitcoinRpc for EsploraClient {
83    fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig {
84        BitcoinRpcConfig {
85            kind: "esplora".to_string(),
86            url: self.url.clone(),
87        }
88    }
89
90    fn get_url(&self) -> SafeUrl {
91        self.url.clone()
92    }
93
94    async fn get_block_count(&self) -> anyhow::Result<u64> {
95        match self.client.get_height().await {
96            Ok(height) => Ok(u64::from(height) + 1),
97            Err(e) => Err(e.into()),
98        }
99    }
100
101    async fn get_block_hash(&self, height: u64) -> anyhow::Result<BlockHash> {
102        Ok(self.client.get_block_hash(u32::try_from(height)?).await?)
103    }
104
105    async fn get_block(&self, block_hash: &BlockHash) -> anyhow::Result<bitcoin::Block> {
106        let block = self
107            .client
108            .get_block_by_hash(block_hash)
109            .await?
110            .context("Block with this hash is not available")?;
111        validate_block(block, block_hash)
112    }
113
114    async fn get_feerate(&self) -> anyhow::Result<Option<Feerate>> {
115        let fee_estimates = self.client.get_fee_estimates().await?;
116        let fee_rate = esplora_client::convert_fee_rate(1, fee_estimates)
117            .unwrap_or(bitcoin::FeeRate::BROADCAST_MIN);
118
119        Ok(Some(Feerate {
120            // One virtual byte is four weight units.
121            sats_per_kvb: fee_rate
122                .to_sat_per_kwu()
123                .checked_mul(4)
124                .context("Esplora fee rate exceeds sats/kvB range")?,
125        }))
126    }
127
128    async fn submit_transaction(&self, transaction: Transaction) -> anyhow::Result<()> {
129        // Preserve server rejections, including already-known transactions.
130        // Callers retry broadcasts and must not treat acceptance as confirmation.
131        self.client.broadcast(&transaction).await?;
132        Ok(())
133    }
134
135    async fn get_sync_progress(&self) -> anyhow::Result<Option<f64>> {
136        Ok(None)
137    }
138
139    async fn get_chain_id(&self) -> anyhow::Result<ChainId> {
140        if let Some(chain_id) = self.cached_chain_id.get() {
141            return Ok(*chain_id);
142        }
143
144        let chain_id = ChainId::new(self.get_block_hash(1).await?);
145        let _ = self.cached_chain_id.set(chain_id);
146        Ok(chain_id)
147    }
148}