Skip to main content

lexe_common/api/
fiat_rates.rs

1//! Data types returned from the fiat exchange rate API.
2
3use std::{collections::BTreeMap, error::Error, fmt, str::FromStr};
4
5use lexe_std::const_utils::const_result_unwrap;
6#[cfg(any(test, feature = "test-utils"))]
7use proptest_derive::Arbitrary;
8use serde::{Deserialize, Serialize};
9use serde_with::DeserializeFromStr;
10
11use crate::time::TimestampMs;
12
13/// Currency ISO 4217 code. Intended to _only_ cover fiat currencies. For our
14/// purposes, a fiat currency code is _always_ three uppercase ASCII characters
15/// (i.e., `[A-Z]{3}`).
16///
17/// ### Examples
18///
19/// `"USD", "EUR", "DKK", "CNY", ...`
20#[derive(Copy, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)]
21#[derive(DeserializeFromStr)]
22pub struct IsoCurrencyCode([u8; 3]);
23
24/// The BTC price in a given fiat currency.
25///
26/// We just return this as an `f64`, which is kind of haram but also super
27/// convenient. Fortunately, all our accounting is done using BTC and we only
28/// use these exchange rates for display purposes, so it's probably OK?
29#[derive(Copy, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(transparent)]
31#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
32pub struct FiatBtcPrice(pub f64);
33
34/// A quote for various fiat<->BTC exchange rates.
35///
36/// The mobile app client always requests the full set of exchange rates, since
37/// the serialized, uncompressed size is not too big (~2.5 KiB). Using the full
38/// set reduces some client complexity and is easier to cache.
39///
40/// ### Example
41///
42/// ```json
43/// {
44///     "timestamp_ms": 1680228982999,
45///     "rates": {
46///         "EUR": 26168.988183514073,
47///         "USD": 28401.980274690515,
48///         // ..
49///     }
50/// }
51/// ```
52#[derive(Debug, PartialEq, Serialize, Deserialize)]
53#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
54pub struct FiatRates {
55    /// The unix timestamp of the fiat<->BTC exchange rate quotes from the
56    /// upstream data source.
57    pub timestamp_ms: TimestampMs,
58    /// A mapping from fiat symbol (e.g., "USD", "EUR") to the current BTC
59    /// price in that fiat currency (e.g., "USD" => $28,401.98 per BTC).
60    ///
61    /// We store and serialize this map in sorted order so it's easier to scan.
62    pub rates: BTreeMap<IsoCurrencyCode, FiatBtcPrice>,
63}
64
65/// An error from parsing an [`IsoCurrencyCode`].
66#[derive(Copy, Clone, Debug)]
67pub enum ParseError {
68    BadLength,
69    BadCharacter,
70}
71
72// --- impl FiatRates --- //
73
74impl FiatRates {
75    pub fn dummy() -> Self {
76        Self {
77            timestamp_ms: TimestampMs::now(),
78            rates: BTreeMap::from_iter([
79                (IsoCurrencyCode::USD, FiatBtcPrice(67086.56654977065)),
80                (IsoCurrencyCode::EUR, FiatBtcPrice(62965.97545915064)),
81            ]),
82        }
83    }
84}
85
86// --- impl IsoCurrencyCode --- //
87
88impl IsoCurrencyCode {
89    pub const USD: Self = const_result_unwrap(Self::try_from_bytes(*b"USD"));
90    pub const EUR: Self = const_result_unwrap(Self::try_from_bytes(*b"EUR"));
91    // technically not a fiat, but useful
92    pub const BTC: Self = const_result_unwrap(Self::try_from_bytes(*b"BTC"));
93
94    #[inline]
95    pub fn as_str(&self) -> &str {
96        // SAFETY: we guarantee that IsoCurrencyCode is always uppercase ASCII.
97        unsafe { std::str::from_utf8_unchecked(self.0.as_slice()) }
98    }
99
100    #[inline]
101    const fn try_from_bytes(value: [u8; 3]) -> Result<Self, ParseError> {
102        let [c0, c1, c2] = value;
103        // Do it like this so we can use it in `const`
104        if c0.is_ascii_uppercase()
105            && c1.is_ascii_uppercase()
106            && c2.is_ascii_uppercase()
107        {
108            Ok(Self(value))
109        } else {
110            Err(ParseError::BadCharacter)
111        }
112    }
113}
114
115impl FromStr for IsoCurrencyCode {
116    type Err = ParseError;
117    fn from_str(s: &str) -> Result<Self, Self::Err> {
118        let inner = <[u8; 3]>::try_from(s.as_bytes())
119            .map_err(|_| ParseError::BadLength)?;
120        Self::try_from_bytes(inner)
121    }
122}
123
124// impl Borrow<str> for IsoCurrencyCode {
125//     #[inline]
126//     fn borrow(&self) -> &str {
127//         self.as_str()
128//     }
129// }
130
131impl fmt::Display for IsoCurrencyCode {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        fmt::Display::fmt(self.as_str(), f)
134    }
135}
136
137impl fmt::Debug for IsoCurrencyCode {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        fmt::Debug::fmt(self.as_str(), f)
140    }
141}
142
143impl Serialize for IsoCurrencyCode {
144    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
145    where
146        S: serde::Serializer,
147    {
148        self.as_str().serialize(serializer)
149    }
150}
151
152// --- impl ParseError --- //
153
154impl ParseError {
155    fn as_str(&self) -> &'static str {
156        match *self {
157            Self::BadLength =>
158                "IsoCurrencyCode: must be exactly 3 characters long",
159            Self::BadCharacter =>
160                "IsoCurrencyCode: must be all uppercase ASCII",
161        }
162    }
163}
164
165impl fmt::Display for ParseError {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.write_str(self.as_str())
168    }
169}
170
171impl Error for ParseError {}
172
173// --- impl FiatBtcPrice --- //
174
175impl fmt::Debug for FiatBtcPrice {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        self.0.fmt(f)
178    }
179}
180
181#[cfg(any(test, feature = "test-utils"))]
182mod arbitrary_impl {
183    use proptest::{
184        array::uniform3,
185        prelude::Arbitrary,
186        strategy::{BoxedStrategy, Strategy},
187    };
188
189    use super::IsoCurrencyCode;
190
191    impl Arbitrary for IsoCurrencyCode {
192        type Parameters = ();
193        type Strategy = BoxedStrategy<Self>;
194
195        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
196            uniform3(b'A'..=b'Z')
197                .prop_map(|code| IsoCurrencyCode::try_from_bytes(code).unwrap())
198                .boxed()
199        }
200    }
201}
202
203#[cfg(test)]
204mod test {
205    use super::*;
206    use crate::test_utils::roundtrip;
207
208    #[test]
209    fn json_roundtrips() {
210        roundtrip::json_string_roundtrip_proptest::<IsoCurrencyCode>();
211        roundtrip::json_value_roundtrip_proptest::<FiatRates>();
212    }
213}