fedimint_bitcoind/
bitcoincore.rs

1use std::env;
2use std::path::PathBuf;
3
4use anyhow::{anyhow as format_err, bail};
5use bitcoin::{BlockHash, Network, ScriptBuf, Transaction, Txid};
6use bitcoincore_rpc::bitcoincore_rpc_json::EstimateMode;
7use bitcoincore_rpc::{Auth, RpcApi};
8use fedimint_core::encoding::Decodable;
9use fedimint_core::envs::{BitcoinRpcConfig, FM_BITCOIND_COOKIE_FILE_ENV};
10use fedimint_core::module::registry::ModuleDecoderRegistry;
11use fedimint_core::runtime::block_in_place;
12use fedimint_core::txoproof::TxOutProof;
13use fedimint_core::util::SafeUrl;
14use fedimint_core::{Feerate, apply, async_trait_maybe_send};
15use fedimint_logging::{LOG_BITCOIND_CORE, LOG_CORE};
16use tracing::{info, warn};
17
18use crate::{DynBitcoindRpc, IBitcoindRpc, IBitcoindRpcFactory};
19
20#[derive(Debug)]
21pub struct BitcoindFactory;
22
23impl IBitcoindRpcFactory for BitcoindFactory {
24    fn create_connection(&self, url: &SafeUrl) -> anyhow::Result<DynBitcoindRpc> {
25        Ok(BitcoindClient::new(url)?.into())
26    }
27}
28
29#[derive(Debug)]
30struct BitcoindClient {
31    client: ::bitcoincore_rpc::Client,
32    url: SafeUrl,
33}
34
35impl BitcoindClient {
36    fn new(url: &SafeUrl) -> anyhow::Result<Self> {
37        let safe_url = url.clone();
38        let (url, auth) = from_url_to_url_auth(url)?;
39        Ok(Self {
40            client: ::bitcoincore_rpc::Client::new(&url, auth)?,
41            url: safe_url,
42        })
43    }
44}
45
46#[apply(async_trait_maybe_send!)]
47impl IBitcoindRpc for BitcoindClient {
48    async fn get_network(&self) -> anyhow::Result<Network> {
49        let network = block_in_place(|| self.client.get_blockchain_info())?;
50        Ok(network.chain)
51    }
52
53    async fn get_block_count(&self) -> anyhow::Result<u64> {
54        // The RPC function is confusingly named and actually returns the block height
55        block_in_place(|| self.client.get_block_count())
56            .map(|height| height + 1)
57            .map_err(anyhow::Error::from)
58    }
59
60    async fn get_block_hash(&self, height: u64) -> anyhow::Result<BlockHash> {
61        block_in_place(|| self.client.get_block_hash(height)).map_err(anyhow::Error::from)
62    }
63
64    async fn get_block(&self, hash: &BlockHash) -> anyhow::Result<bitcoin::Block> {
65        block_in_place(|| self.client.get_block(hash)).map_err(anyhow::Error::from)
66    }
67
68    async fn get_fee_rate(&self, confirmation_target: u16) -> anyhow::Result<Option<Feerate>> {
69        let fee = block_in_place(|| {
70            self.client
71                .estimate_smart_fee(confirmation_target, Some(EstimateMode::Conservative))
72        });
73        Ok(fee?.fee_rate.map(|per_kb| Feerate {
74            sats_per_kvb: per_kb.to_sat(),
75        }))
76    }
77
78    async fn submit_transaction(&self, transaction: Transaction) {
79        use bitcoincore_rpc::Error::JsonRpc;
80        use bitcoincore_rpc::jsonrpc::Error::Rpc;
81        match block_in_place(|| self.client.send_raw_transaction(&transaction)) {
82            // Bitcoin core's RPC will return error code -27 if a transaction is already in a block.
83            // This is considered a success case, so we don't surface the error log.
84            //
85            // https://github.com/bitcoin/bitcoin/blob/daa56f7f665183bcce3df146f143be37f33c123e/src/rpc/protocol.h#L48
86            Err(JsonRpc(Rpc(e))) if e.code == -27 => (),
87            Err(e) => info!(target: LOG_BITCOIND_CORE, ?e, "Error broadcasting transaction"),
88            Ok(_) => (),
89        }
90    }
91
92    async fn get_tx_block_height(&self, txid: &Txid) -> anyhow::Result<Option<u64>> {
93        let info = block_in_place(|| self.client.get_raw_transaction_info(txid, None)).map_err(
94            |error| info!(target: LOG_BITCOIND_CORE, ?error, "Unable to get raw transaction"),
95        );
96        let height = match info.ok().and_then(|info| info.blockhash) {
97            None => None,
98            Some(hash) => Some(block_in_place(|| self.client.get_block_header_info(&hash))?.height),
99        };
100        Ok(height.map(|h| h as u64))
101    }
102
103    async fn is_tx_in_block(
104        &self,
105        txid: &Txid,
106        block_hash: &BlockHash,
107        block_height: u64,
108    ) -> anyhow::Result<bool> {
109        let block_info = block_in_place(|| self.client.get_block_info(block_hash))?;
110        anyhow::ensure!(
111            block_info.height as u64 == block_height,
112            "Block height for block hash does not match expected height"
113        );
114        Ok(block_info.tx.contains(txid))
115    }
116
117    async fn watch_script_history(&self, script: &ScriptBuf) -> anyhow::Result<()> {
118        warn!(target: LOG_CORE, "Wallet operations are broken on bitcoind. Use different backend.");
119        // start watching for this script in our wallet to avoid the need to rescan the
120        // blockchain, labeling it so we can reference it later
121        block_in_place(|| {
122            self.client
123                .import_address_script(script, Some(&script.to_string()), Some(false), None)
124        })?;
125
126        Ok(())
127    }
128
129    async fn get_script_history(&self, script: &ScriptBuf) -> anyhow::Result<Vec<Transaction>> {
130        let mut results = vec![];
131        let list = block_in_place(|| {
132            self.client
133                .list_transactions(Some(&script.to_string()), None, None, Some(true))
134        })?;
135        for tx in list {
136            let raw_tx = block_in_place(|| self.client.get_raw_transaction(&tx.info.txid, None))?;
137            results.push(raw_tx);
138        }
139        Ok(results)
140    }
141
142    async fn get_txout_proof(&self, txid: Txid) -> anyhow::Result<TxOutProof> {
143        TxOutProof::consensus_decode_whole(
144            &block_in_place(|| self.client.get_tx_out_proof(&[txid], None))?,
145            &ModuleDecoderRegistry::default(),
146        )
147        .map_err(|error| format_err!("Could not decode tx: {}", error))
148    }
149
150    async fn get_sync_percentage(&self) -> anyhow::Result<Option<f64>> {
151        let blockchain_info = block_in_place(|| self.client.get_blockchain_info())?;
152        Ok(Some(blockchain_info.verification_progress))
153    }
154
155    fn get_bitcoin_rpc_config(&self) -> BitcoinRpcConfig {
156        BitcoinRpcConfig {
157            kind: "bitcoind".to_string(),
158            url: self.url.clone(),
159        }
160    }
161}
162
163// TODO: Make private
164pub fn from_url_to_url_auth(url: &SafeUrl) -> anyhow::Result<(String, Auth)> {
165    Ok((
166        (if let Some(port) = url.port() {
167            format!(
168                "{}://{}:{port}",
169                url.scheme(),
170                url.host_str().unwrap_or("127.0.0.1")
171            )
172        } else {
173            format!(
174                "{}://{}",
175                url.scheme(),
176                url.host_str().unwrap_or("127.0.0.1")
177            )
178        }),
179        match (
180            !url.username().is_empty(),
181            env::var(FM_BITCOIND_COOKIE_FILE_ENV),
182        ) {
183            (true, Ok(_)) => {
184                bail!("When {FM_BITCOIND_COOKIE_FILE_ENV} is set, the url auth part must be empty.")
185            }
186            (true, Err(_)) => Auth::UserPass(
187                url.username().to_owned(),
188                url.password()
189                    .ok_or_else(|| format_err!("Password missing for {}", url.username()))?
190                    .to_owned(),
191            ),
192            (false, Ok(path)) => Auth::CookieFile(PathBuf::from(path)),
193            (false, Err(_)) => Auth::None,
194        },
195    ))
196}