fedimint_wallet_server/
feerate_source.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
use std::str::FromStr;

use anyhow::{anyhow, bail, Result};
use fedimint_bitcoind::DynBitcoindRpc;
use fedimint_core::util::SafeUrl;
use fedimint_core::{apply, async_trait_maybe_send, Feerate};
use fedimint_logging::LOG_MODULE_WALLET;
use fedimint_wallet_common::CONFIRMATION_TARGET;
use jaq_core::load::{Arena, File, Loader};
use jaq_core::{Ctx, Native, RcIter};
use jaq_json::Val;
use tracing::{debug, trace};

/// A feerate that we don't expect to ever happen in practice, that we are
/// going to reject from a source to help catching mistakes and
/// misconfigurations.
const FEERATE_SOURCE_MAX_FEERATE_SATS_PER_VB: f64 = 10_000.0;

/// Like [`FEERATE_SOURCE_MAX_FEERATE_SATS_PER_VB`], but minimum one we accept
const FEERATE_SOURCE_MIN_FEERATE_SATS_PER_VB: f64 = 1.0;

#[apply(async_trait_maybe_send!)]
pub trait FeeRateSource: Send + Sync {
    fn name(&self) -> String;
    async fn fetch(&self) -> Result<Feerate>;
}

#[apply(async_trait_maybe_send!)]
impl FeeRateSource for DynBitcoindRpc {
    fn name(&self) -> String {
        self.get_bitcoin_rpc_config().kind
    }

    async fn fetch(&self) -> Result<Feerate> {
        self.get_fee_rate(CONFIRMATION_TARGET)
            .await?
            .ok_or_else(|| anyhow!("bitcoind did not return any feerate"))
    }
}

pub struct FetchJson {
    filter: jaq_core::Filter<Native<Val>>,
    source_url: SafeUrl,
}

impl FetchJson {
    pub fn from_str(source_str: &str) -> Result<Self> {
        let (source_url, code) = {
            let (url, code) = match source_str.split_once('#') {
                Some(val) => val,
                None => (source_str, "."),
            };

            (SafeUrl::parse(url)?, code)
        };

        debug!(target: LOG_MODULE_WALLET, url = %source_url, code = %code, "Setting fee rate json source");
        let program = File { code, path: () };

        let loader = Loader::new([]);
        let arena = Arena::default();
        let modules = loader.load(&arena, program).map_err(|errs| {
            anyhow!(
                "Error parsing jq filter for {source_url}: {}",
                errs.into_iter()
                    .map(|e| format!("{e:?}"))
                    .collect::<Vec<_>>()
                    .join("\n")
            )
        })?;

        let filter = jaq_core::Compiler::<_, Native<_>>::default()
            .compile(modules)
            .map_err(|errs| anyhow!("Failed to compile program: {:?}", errs))?;

        Ok(Self { filter, source_url })
    }

    fn apply_filter(&self, value: serde_json::Value) -> Result<Val> {
        let inputs = RcIter::new(core::iter::empty());

        let mut out = self.filter.run((Ctx::new([], &inputs), Val::from(value)));

        out.next()
            .ok_or_else(|| anyhow!("Missing value after applying filter"))?
            .map_err(|e| anyhow!("Jaq err: {e}"))
    }
}

#[apply(async_trait_maybe_send!)]
impl FeeRateSource for FetchJson {
    fn name(&self) -> String {
        self.source_url
            .host()
            .map_or_else(|| "host-not-available".to_string(), |h| h.to_string())
    }

    async fn fetch(&self) -> Result<Feerate> {
        let json_resp: serde_json::Value = reqwest::get(self.source_url.clone().to_unsafe())
            .await?
            .json()
            .await?;

        trace!(target: LOG_MODULE_WALLET, name = %self.name(), resp = ?json_resp, "Got json response");

        let val = self.apply_filter(json_resp)?;

        let rate = match val {
            Val::Float(rate) => rate,
            #[allow(clippy::cast_precision_loss)]
            Val::Int(rate) => rate as f64,
            Val::Num(rate) => FromStr::from_str(&rate)?,
            _ => {
                bail!("Value returned by feerate source has invalid type: {val:?}");
            }
        };
        debug!(target: LOG_MODULE_WALLET, name = %self.name(), rate_sats_vb = %rate, "Got fee rate");

        if rate < FEERATE_SOURCE_MIN_FEERATE_SATS_PER_VB {
            bail!("Fee rate returned by source not positive: {rate}")
        }

        if FEERATE_SOURCE_MAX_FEERATE_SATS_PER_VB <= rate {
            bail!("Fee rate returned by source too large: {rate}")
        }

        Ok(Feerate {
            // just checked that it's not negative
            #[allow(clippy::cast_sign_loss)]
            sats_per_kvb: (rate * 1000.0).floor() as u64,
        })
    }
}

#[cfg(test)]
mod test {
    use std::rc::Rc;

    use jaq_json::Val;

    use crate::feerate_source::FetchJson;

    fn val_str(s: &str) -> Val {
        Val::Str(Rc::new(s.to_owned()))
    }

    #[test]
    fn test_filter() {
        let source_id = FetchJson::from_str("https://example.com#.").expect("Failed to parse url");
        assert_eq!(
            source_id
                .apply_filter(serde_json::json!("foo"))
                .expect("Failed to apply filter"),
            val_str("foo")
        );

        let source_access_member =
            FetchJson::from_str("https://example.com#.[0].foo").expect("Failed to parse url");
        assert_eq!(
            source_access_member
                .apply_filter(serde_json::json!([{"foo": "bar"}, 1, 2, 3]))
                .expect("Failed to apply filter"),
            val_str("bar")
        );
    }
}