fedimint_bitcoind/
bitcoincore.rs

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