/ fedimint-bitcoind / src / electrum.rs
electrum.rs
  1  use std::fmt;
  2  
  3  use anyhow::anyhow as format_err;
  4  use bitcoin::{BlockHash, Network, ScriptBuf, Transaction, Txid};
  5  use electrum_client::ElectrumApi;
  6  use electrum_client::Error::Protocol;
  7  use fedimint_core::runtime::block_in_place;
  8  use fedimint_core::task::TaskHandle;
  9  use fedimint_core::txoproof::TxOutProof;
 10  use fedimint_core::util::SafeUrl;
 11  use fedimint_core::{apply, async_trait_maybe_send, Feerate};
 12  use hex::ToHex;
 13  use serde_json::{Map, Value};
 14  use tracing::{info, warn};
 15  
 16  use crate::{DynBitcoindRpc, IBitcoindRpc, IBitcoindRpcFactory, RetryClient};
 17  
 18  #[derive(Debug)]
 19  pub struct ElectrumFactory;
 20  
 21  impl IBitcoindRpcFactory for ElectrumFactory {
 22      fn create_connection(
 23          &self,
 24          url: &SafeUrl,
 25          handle: TaskHandle,
 26      ) -> anyhow::Result<DynBitcoindRpc> {
 27          Ok(RetryClient::new(ElectrumClient::new(url)?, handle).into())
 28      }
 29  }
 30  
 31  pub struct ElectrumClient(electrum_client::Client);
 32  
 33  impl ElectrumClient {
 34      fn new(url: &SafeUrl) -> anyhow::Result<Self> {
 35          Ok(Self(electrum_client::Client::new(url.as_str())?))
 36      }
 37  }
 38  
 39  impl fmt::Debug for ElectrumClient {
 40      fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 41          f.write_str("ElectrumClient")
 42      }
 43  }
 44  
 45  #[apply(async_trait_maybe_send!)]
 46  impl IBitcoindRpc for ElectrumClient {
 47      async fn get_network(&self) -> anyhow::Result<Network> {
 48          let resp = block_in_place(|| self.0.server_features())?;
 49          Ok(match resp.genesis_hash.encode_hex::<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              hash => {
 54                  warn!("Unknown genesis hash {hash} - assuming regtest");
 55                  Network::Regtest
 56              }
 57          })
 58      }
 59  
 60      async fn get_block_count(&self) -> anyhow::Result<u64> {
 61          Ok(block_in_place(|| self.0.block_headers_subscribe_raw())?.height as u64 + 1)
 62      }
 63  
 64      async fn get_block_hash(&self, height: u64) -> anyhow::Result<BlockHash> {
 65          let result = block_in_place(|| self.0.block_headers(height as usize, 1))?;
 66          Ok(result
 67              .headers
 68              .first()
 69              .ok_or_else(|| format_err!("empty block headers response"))?
 70              .block_hash())
 71      }
 72  
 73      async fn get_fee_rate(&self, confirmation_target: u16) -> anyhow::Result<Option<Feerate>> {
 74          let estimate = block_in_place(|| self.0.estimate_fee(confirmation_target as usize))?;
 75          let min_fee = block_in_place(|| self.0.relay_fee())?;
 76  
 77          // convert fee rate estimate or min fee to sats
 78          let sats_per_kvb = estimate.max(min_fee) * 100_000_000f64;
 79          Ok(Some(Feerate {
 80              sats_per_kvb: sats_per_kvb.ceil() as u64,
 81          }))
 82      }
 83  
 84      async fn submit_transaction(&self, transaction: Transaction) {
 85          let mut bytes = vec![];
 86          bitcoin::consensus::Encodable::consensus_encode(&transaction, &mut bytes)
 87              .expect("can't fail");
 88          match block_in_place(|| self.0.transaction_broadcast_raw(&bytes)) {
 89              Err(Protocol(Value::Object(e))) if is_already_submitted_error(&e) => (),
 90              Err(e) => info!(?e, "Error broadcasting transaction"),
 91              Ok(_) => (),
 92          }
 93      }
 94  
 95      async fn get_tx_block_height(&self, txid: &Txid) -> anyhow::Result<Option<u64>> {
 96          let tx = block_in_place(|| self.0.transaction_get(txid))
 97              .map_err(|error| info!(?error, "Unable to get raw transaction"));
 98          match tx.ok() {
 99              None => Ok(None),
100              Some(tx) => {
101                  let output = tx
102                      .output
103                      .first()
104                      .ok_or(format_err!("Transaction must contain at least one output"))?;
105                  let history = block_in_place(|| self.0.script_get_history(&output.script_pubkey))?;
106                  Ok(history.first().map(|history| history.height as u64))
107              }
108          }
109      }
110  
111      async fn watch_script_history(&self, _: &ScriptBuf) -> anyhow::Result<()> {
112          // no watching needed on electrs, has all the history already
113          Ok(())
114      }
115  
116      async fn get_script_history(
117          &self,
118          script: &ScriptBuf,
119      ) -> anyhow::Result<Vec<bitcoin::Transaction>> {
120          let mut results = vec![];
121          let transactions = block_in_place(|| self.0.script_get_history(script))?;
122          for history in transactions.into_iter() {
123              results.push(block_in_place(|| self.0.transaction_get(&history.tx_hash))?);
124          }
125          Ok(results)
126      }
127  
128      async fn get_txout_proof(&self, _txid: Txid) -> anyhow::Result<TxOutProof> {
129          // FIXME: Not sure how to implement for electrum yet, but the client cannot use
130          // electrum regardless right now
131          unimplemented!()
132      }
133  }
134  
135  /// Parses errors from electrum-client to determine if the transaction is
136  /// already submitted and can be ignored.
137  ///
138  /// Electrs [maps] daemon errors to a generic error code (2) instead of using
139  /// the error codes returned from bitcoin core's RPC (-27). There's an open [PR]
140  /// to use the correct error codes, but until that's available we match the
141  /// error based on the message text.
142  ///
143  /// [maps]: https://github.com/romanz/electrs/blob/v0.9.13/src/electrum.rs#L110
144  /// [PR]: https://github.com/romanz/electrs/pull/942
145  fn is_already_submitted_error(error: &Map<String, Value>) -> bool {
146      // TODO: Filter `electrs` errors using codes instead of string when available in
147      // `electrum-client`
148      // https://github.com/fedimint/fedimint/issues/3731
149      match error.get("message").and_then(|value| value.as_str()) {
150          Some(message) => message == "Transaction already in block chain",
151          None => false,
152      }
153  }
154  
155  #[cfg(test)]
156  mod tests {
157      use serde_json::{json, Map, Value};
158  
159      use crate::electrum::is_already_submitted_error;
160  
161      fn message_to_json(message: &str) -> Map<String, Value> {
162          let as_value = json!({"code": 2, "message": message});
163          as_value
164              .as_object()
165              .expect("should parse as object")
166              .to_owned()
167      }
168  
169      #[test]
170      fn should_parse_transaction_already_submitted_errors() {
171          let already_submitted_error = message_to_json("Transaction already in block chain");
172          assert!(is_already_submitted_error(&already_submitted_error));
173  
174          let different_error_message =
175              message_to_json("Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate");
176          assert!(!is_already_submitted_error(&different_error_message));
177  
178          let unknown_error_object = message_to_json("");
179          assert!(!is_already_submitted_error(&unknown_error_object));
180      }
181  }