fedimint_bitcoind/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_truncation)]
3#![allow(clippy::cast_sign_loss)]
4#![allow(clippy::missing_errors_doc)]
5#![allow(clippy::missing_panics_doc)]
6#![allow(clippy::module_name_repetitions)]
7#![allow(clippy::similar_names)]
8
9use std::env;
10use std::fmt::Debug;
11use std::sync::Arc;
12
13use anyhow::{Result, format_err};
14use bitcoin::{ScriptBuf, Transaction, Txid};
15use esplora_client::{AsyncClient, Builder};
16use fedimint_core::envs::FM_FORCE_BITCOIN_RPC_URL_ENV;
17use fedimint_core::txoproof::TxOutProof;
18use fedimint_core::util::SafeUrl;
19use fedimint_core::{apply, async_trait_maybe_send};
20
21pub fn create_esplora_rpc(url: &SafeUrl) -> Result<DynBitcoindRpc> {
22    let url = env::var(FM_FORCE_BITCOIN_RPC_URL_ENV)
23        .ok()
24        .map(|s| SafeUrl::parse(&s))
25        .transpose()?
26        .unwrap_or_else(|| url.clone());
27
28    Ok(EsploraClient::new(&url)?.into_dyn())
29}
30
31pub type DynBitcoindRpc = Arc<dyn IBitcoindRpc + Send + Sync>;
32
33/// Trait that allows interacting with the Bitcoin blockchain
34///
35/// Functions may panic if the bitcoind node is not reachable.
36#[apply(async_trait_maybe_send!)]
37pub trait IBitcoindRpc: Debug + Send + Sync + 'static {
38    /// If a transaction is included in a block, returns the block height.
39    async fn get_tx_block_height(&self, txid: &Txid) -> Result<Option<u64>>;
40
41    /// Get script transaction history
42    async fn get_script_history(&self, script: &ScriptBuf) -> Result<Vec<Transaction>>;
43
44    /// Returns a proof that a tx is included in the bitcoin blockchain
45    async fn get_txout_proof(&self, txid: Txid) -> Result<TxOutProof>;
46
47    fn into_dyn(self) -> DynBitcoindRpc
48    where
49        Self: Sized,
50    {
51        Arc::new(self)
52    }
53}
54
55#[derive(Debug)]
56pub struct EsploraClient {
57    client: AsyncClient,
58}
59
60impl EsploraClient {
61    pub fn new(url: &SafeUrl) -> anyhow::Result<Self> {
62        Ok(Self {
63            // URL needs to have any trailing path including '/' removed
64            client: Builder::new(url.as_str().trim_end_matches('/')).build_async()?,
65        })
66    }
67}
68
69#[apply(async_trait_maybe_send!)]
70impl IBitcoindRpc for EsploraClient {
71    async fn get_tx_block_height(&self, txid: &Txid) -> anyhow::Result<Option<u64>> {
72        Ok(self
73            .client
74            .get_tx_status(txid)
75            .await?
76            .block_height
77            .map(u64::from))
78    }
79
80    async fn get_script_history(
81        &self,
82        script: &ScriptBuf,
83    ) -> anyhow::Result<Vec<bitcoin::Transaction>> {
84        const MAX_TX_HISTORY: usize = 1000;
85
86        let mut transactions = Vec::new();
87        let mut last_seen: Option<Txid> = None;
88
89        loop {
90            let page = self.client.scripthash_txs(script, last_seen).await?;
91
92            if page.is_empty() {
93                break;
94            }
95
96            for tx in &page {
97                transactions.push(tx.to_tx());
98            }
99
100            if transactions.len() >= MAX_TX_HISTORY {
101                return Err(format_err!(
102                    "Script history exceeds maximum limit of {}",
103                    MAX_TX_HISTORY
104                ));
105            }
106
107            last_seen = Some(page.last().expect("page not empty").txid);
108        }
109
110        Ok(transactions)
111    }
112
113    async fn get_txout_proof(&self, txid: Txid) -> anyhow::Result<TxOutProof> {
114        let proof = self
115            .client
116            .get_merkle_block(&txid)
117            .await?
118            .ok_or(format_err!("No merkle proof found"))?;
119
120        Ok(TxOutProof {
121            block_header: proof.header,
122            merkle_proof: proof.txn,
123        })
124    }
125}