fedimint_bitcoind/
feerate_source.rs

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