fedimint_bitcoind/
esplora.rs

1use std::collections::HashMap;
2
3use anyhow::{Context, bail, format_err};
4use bitcoin::{BlockHash, Network, ScriptBuf, Transaction, Txid};
5use fedimint_core::envs::BitcoinRpcConfig;
6use fedimint_core::txoproof::TxOutProof;
7use fedimint_core::util::SafeUrl;
8use fedimint_core::{Feerate, apply, async_trait_maybe_send};
9use fedimint_logging::LOG_BITCOIND_ESPLORA;
10use tracing::info;
11
12use crate::{DynBitcoindRpc, IBitcoindRpc, IBitcoindRpcFactory};
13
14#[derive(Debug)]
15pub struct EsploraFactory;
16
17impl IBitcoindRpcFactory for EsploraFactory {
18    fn create_connection(&self, url: &SafeUrl) -> anyhow::Result<DynBitcoindRpc> {
19        Ok(EsploraClient::new(url)?.into())
20    }
21}
22
23#[derive(Debug)]
24struct EsploraClient {
25    client: esplora_client::AsyncClient,
26    url: SafeUrl,
27}
28
29impl EsploraClient {
30    fn new(url: &SafeUrl) -> anyhow::Result<Self> {
31        // URL needs to have any trailing path including '/' removed
32        let without_trailing = url.as_str().trim_end_matches('/');
33
34        let builder = esplora_client::Builder::new(without_trailing);
35        let client = builder.build_async()?;
36        Ok(Self {
37            client,
38            url: url.clone(),
39        })
40    }
41}
42
43#[apply(async_trait_maybe_send!)]
44impl IBitcoindRpc for EsploraClient {
45    async fn get_network(&self) -> anyhow::Result<Network> {
46        let genesis_height: u32 = 0;
47        let genesis_hash = self.client.get_block_hash(genesis_height).await?;
48
49        let network = match genesis_hash.to_string().as_str() {
50            crate::MAINNET_GENESIS_BLOCK_HASH => Network::Bitcoin,
51            crate::TESTNET_GENESIS_BLOCK_HASH => Network::Testnet,
52            crate::SIGNET_GENESIS_BLOCK_HASH => Network::Signet,
53            crate::REGTEST_GENESIS_BLOCK_HASH => Network::Regtest,
54            hash => {
55                bail!("Unknown genesis hash {hash}");
56            }
57        };
58
59        Ok(network)
60    }
61
62    async fn get_block_count(&self) -> anyhow::Result<u64> {
63        match self.client.get_height().await {
64            Ok(height) => Ok(u64::from(height) + 1),
65            Err(e) => Err(e.into()),
66        }
67    }
68
69    async fn get_block_hash(&self, height: u64) -> anyhow::Result<BlockHash> {
70        Ok(self.client.get_block_hash(u32::try_from(height)?).await?)
71    }
72
73    async fn get_block(&self, block_hash: &BlockHash) -> anyhow::Result<bitcoin::Block> {
74        self.client
75            .get_block_by_hash(block_hash)
76            .await?
77            .context("Block with this hash is not available")
78    }
79
80    async fn get_fee_rate(&self, confirmation_target: u16) -> anyhow::Result<Option<Feerate>> {
81        let fee_estimates: HashMap<u16, f64> = self.client.get_fee_estimates().await?;
82
83        let fee_rate_vb =
84            esplora_client::convert_fee_rate(confirmation_target.into(), fee_estimates)
85                .unwrap_or(1.0);
86
87        let fee_rate_kvb = fee_rate_vb * 1_000f32;
88
89        Ok(Some(Feerate {
90            sats_per_kvb: (fee_rate_kvb).ceil() as u64,
91        }))
92    }
93
94    async fn submit_transaction(&self, transaction: Transaction) {
95        let _ = self.client.broadcast(&transaction).await.map_err(|error| {
96            // `esplora-client` v0.6.0 only surfaces HTTP error codes, which prevents us
97            // from detecting errors for transactions already submitted.
98            // TODO: Suppress `esplora-client` already submitted errors when client is
99            // updated
100            // https://github.com/fedimint/fedimint/issues/3732
101            info!(target: LOG_BITCOIND_ESPLORA, ?error, "Error broadcasting transaction");
102        });
103    }
104
105    async fn get_tx_block_height(&self, txid: &Txid) -> anyhow::Result<Option<u64>> {
106        Ok(self
107            .client
108            .get_tx_status(txid)
109            .await?
110            .block_height
111            .map(u64::from))
112    }
113
114    async fn is_tx_in_block(
115        &self,
116        txid: &Txid,
117        block_hash: &BlockHash,
118        block_height: u64,
119    ) -> anyhow::Result<bool> {
120        let tx_status = self.client.get_tx_status(txid).await?;
121
122        let is_in_block_height = tx_status
123            .block_height
124            .is_some_and(|height| u64::from(height) == block_height);
125
126        if is_in_block_height {
127            let tx_block_hash = tx_status.block_hash.ok_or(anyhow::format_err!(
128                "Tx has a block height without a block hash"
129            ))?;
130            anyhow::ensure!(
131                block_hash == &tx_block_hash,
132                "Block height for block hash does not match expected height"
133            );
134        }
135
136        Ok(is_in_block_height)
137    }
138
139    async fn watch_script_history(&self, _: &ScriptBuf) -> anyhow::Result<()> {
140        // no watching needed, has all the history already
141        Ok(())
142    }
143
144    async fn get_script_history(
145        &self,
146        script: &ScriptBuf,
147    ) -> anyhow::Result<Vec<bitcoin::Transaction>> {
148        let transactions = self
149            .client
150            .scripthash_txs(script, None)
151            .await?
152            .into_iter()
153            .map(|tx| tx.to_tx())
154            .collect::<Vec<_>>();
155
156        Ok(transactions)
157    }
158
159    async fn get_txout_proof(&self, txid: Txid) -> anyhow::Result<TxOutProof> {
160        let proof = self
161            .client
162            .get_merkle_block(&txid)
163            .await?
164            .ok_or(format_err!("No merkle proof found"))?;
165
166        Ok(TxOutProof {
167            block_header: proof.header,
168            merkle_proof: proof.txn,
169        })
170    }
171
172    async fn get_sync_percentage(&self) -> anyhow::Result<Option<f64>> {
173        Ok(None)
174    }
175
176    fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig {
177        BitcoinRpcConfig {
178            kind: "esplora".to_string(),
179            url: self.url.clone(),
180        }
181    }
182}