fedimint_server_bitcoin_rpc/
esplora.rs1#[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
19fn 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 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 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 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 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}