/ modules / fedimint-wallet-server / src / feerate_source.rs
feerate_source.rs
  1  use std::str::FromStr;
  2  
  3  use anyhow::{anyhow, bail, Result};
  4  use fedimint_bitcoind::DynBitcoindRpc;
  5  use fedimint_core::util::SafeUrl;
  6  use fedimint_core::{apply, async_trait_maybe_send, Feerate};
  7  use fedimint_logging::LOG_MODULE_WALLET;
  8  use fedimint_wallet_common::CONFIRMATION_TARGET;
  9  use jaq_core::load::{Arena, File, Loader};
 10  use jaq_core::{Ctx, Native, RcIter};
 11  use jaq_json::Val;
 12  use tracing::{debug, trace};
 13  
 14  /// A feerate that we don't expect to ever happen in practice, that we are
 15  /// going to reject from a source to help catching mistakes and
 16  /// misconfigurations.
 17  const FEERATE_SOURCE_MAX_FEERATE_SATS_PER_VB: f64 = 10_000.0;
 18  
 19  /// Like [`FEERATE_SOURCE_MAX_FEERATE_SATS_PER_VB`], but minimum one we accept
 20  const FEERATE_SOURCE_MIN_FEERATE_SATS_PER_VB: f64 = 1.0;
 21  
 22  #[apply(async_trait_maybe_send!)]
 23  pub trait FeeRateSource: Send + Sync {
 24      fn name(&self) -> String;
 25      async fn fetch(&self) -> Result<Feerate>;
 26  }
 27  
 28  #[apply(async_trait_maybe_send!)]
 29  impl FeeRateSource for DynBitcoindRpc {
 30      fn name(&self) -> String {
 31          self.get_bitcoin_rpc_config().kind
 32      }
 33  
 34      async fn fetch(&self) -> Result<Feerate> {
 35          self.get_fee_rate(CONFIRMATION_TARGET)
 36              .await?
 37              .ok_or_else(|| anyhow!("bitcoind did not return any feerate"))
 38      }
 39  }
 40  
 41  pub struct FetchJson {
 42      filter: jaq_core::Filter<Native<Val>>,
 43      source_url: SafeUrl,
 44  }
 45  
 46  impl FetchJson {
 47      pub fn from_str(source_str: &str) -> Result<Self> {
 48          let (source_url, code) = {
 49              let (url, code) = match source_str.split_once('#') {
 50                  Some(val) => val,
 51                  None => (source_str, "."),
 52              };
 53  
 54              (SafeUrl::parse(url)?, code)
 55          };
 56  
 57          debug!(target: LOG_MODULE_WALLET, url = %source_url, code = %code, "Setting fee rate json source");
 58          let program = File { code, path: () };
 59  
 60          let loader = Loader::new([]);
 61          let arena = Arena::default();
 62          let modules = loader.load(&arena, program).map_err(|errs| {
 63              anyhow!(
 64                  "Error parsing jq filter for {source_url}: {}",
 65                  errs.into_iter()
 66                      .map(|e| format!("{e:?}"))
 67                      .collect::<Vec<_>>()
 68                      .join("\n")
 69              )
 70          })?;
 71  
 72          let filter = jaq_core::Compiler::<_, Native<_>>::default()
 73              .compile(modules)
 74              .map_err(|errs| anyhow!("Failed to compile program: {:?}", errs))?;
 75  
 76          Ok(Self { filter, source_url })
 77      }
 78  
 79      fn apply_filter(&self, value: serde_json::Value) -> Result<Val> {
 80          let inputs = RcIter::new(core::iter::empty());
 81  
 82          let mut out = self.filter.run((Ctx::new([], &inputs), Val::from(value)));
 83  
 84          out.next()
 85              .ok_or_else(|| anyhow!("Missing value after applying filter"))?
 86              .map_err(|e| anyhow!("Jaq err: {e}"))
 87      }
 88  }
 89  
 90  #[apply(async_trait_maybe_send!)]
 91  impl FeeRateSource for FetchJson {
 92      fn name(&self) -> String {
 93          self.source_url
 94              .host()
 95              .map_or_else(|| "host-not-available".to_string(), |h| h.to_string())
 96      }
 97  
 98      async fn fetch(&self) -> Result<Feerate> {
 99          let json_resp: serde_json::Value = reqwest::get(self.source_url.clone().to_unsafe())
100              .await?
101              .json()
102              .await?;
103  
104          trace!(target: LOG_MODULE_WALLET, name = %self.name(), resp = ?json_resp, "Got json response");
105  
106          let val = self.apply_filter(json_resp)?;
107  
108          let rate = match val {
109              Val::Float(rate) => rate,
110              #[allow(clippy::cast_precision_loss)]
111              Val::Int(rate) => rate as f64,
112              Val::Num(rate) => FromStr::from_str(&rate)?,
113              _ => {
114                  bail!("Value returned by feerate source has invalid type: {val:?}");
115              }
116          };
117          debug!(target: LOG_MODULE_WALLET, name = %self.name(), rate_sats_vb = %rate, "Got fee rate");
118  
119          if rate < FEERATE_SOURCE_MIN_FEERATE_SATS_PER_VB {
120              bail!("Fee rate returned by source not positive: {rate}")
121          }
122  
123          if FEERATE_SOURCE_MAX_FEERATE_SATS_PER_VB <= rate {
124              bail!("Fee rate returned by source too large: {rate}")
125          }
126  
127          Ok(Feerate {
128              // just checked that it's not negative
129              #[allow(clippy::cast_sign_loss)]
130              sats_per_kvb: (rate * 1000.0).floor() as u64,
131          })
132      }
133  }
134  
135  #[cfg(test)]
136  mod test {
137      use std::rc::Rc;
138  
139      use jaq_json::Val;
140  
141      use crate::feerate_source::FetchJson;
142  
143      fn val_str(s: &str) -> Val {
144          Val::Str(Rc::new(s.to_owned()))
145      }
146  
147      #[test]
148      fn test_filter() {
149          let source_id = FetchJson::from_str("https://example.com#.").expect("Failed to parse url");
150          assert_eq!(
151              source_id
152                  .apply_filter(serde_json::json!("foo"))
153                  .expect("Failed to apply filter"),
154              val_str("foo")
155          );
156  
157          let source_access_member =
158              FetchJson::from_str("https://example.com#.[0].foo").expect("Failed to parse url");
159          assert_eq!(
160              source_access_member
161                  .apply_filter(serde_json::json!([{"foo": "bar"}, 1, 2, 3]))
162                  .expect("Failed to apply filter"),
163              val_str("bar")
164          );
165      }
166  }