Skip to main content

aauth_protocol/
budget.rs

1//! Budgets ([draft-hardt-aauth-budgets]): an amount a mission may spend, and
2//! what one request costs.
3//!
4//! [draft-hardt-aauth-budgets]: https://github.com/dickhardt/AAuth
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::error::{Error, Result};
10
11/// `amount / 10^decimals` in `unit`: `{"amount": 300000, "unit": "USD",
12/// "decimals": 2}` is $3,000.00.
13#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Budget {
15    /// In the unit's smallest denomination at `decimals`.
16    pub amount: u64,
17    /// An ISO 4217 currency code, or any unit both sides agree on.
18    pub unit: String,
19    /// How many of `amount`'s digits are after the decimal point.
20    pub decimals: u32,
21}
22
23/// Larger amounts lose precision in a JSON number other implementations read.
24const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
25
26impl Budget {
27    /// A budget of `amount` in `unit` at `decimals`.
28    pub fn new(amount: u64, unit: impl Into<String>, decimals: u32) -> Self {
29        Self {
30            amount,
31            unit: unit.into(),
32            decimals,
33        }
34    }
35
36    /// A budget from a JSON claim: `None` when absent or null, and an error
37    /// for anything that is not `{amount, unit, decimals}` with non-negative
38    /// integers no larger than 2^53 - 1.
39    ///
40    /// ```
41    /// # use aauth_protocol::Budget;
42    /// let claim = serde_json::json!({ "amount": 300000, "unit": "USD", "decimals": 2 });
43    /// assert_eq!(Budget::from_value(Some(&claim))?, Some(Budget::new(300_000, "USD", 2)));
44    /// assert_eq!(Budget::from_value(None)?, None);
45    /// assert!(Budget::from_value(Some(&serde_json::json!("$3000"))).is_err());
46    /// # Ok::<(), aauth_protocol::Error>(())
47    /// ```
48    pub fn from_value(value: Option<&Value>) -> Result<Option<Self>> {
49        let Some(value) = value.filter(|v| !v.is_null()) else {
50            return Ok(None);
51        };
52        let invalid = || Error::Invalid("budget is {amount, unit, decimals}".into());
53        let amount = value
54            .get("amount")
55            .and_then(Value::as_u64)
56            .filter(|a| *a <= MAX_SAFE_INTEGER)
57            .ok_or_else(invalid)?;
58        let unit = value
59            .get("unit")
60            .and_then(Value::as_str)
61            .ok_or_else(invalid)?;
62        let decimals = value
63            .get("decimals")
64            .and_then(Value::as_u64)
65            .and_then(|d| u32::try_from(d).ok())
66            .ok_or_else(invalid)?;
67        Ok(Some(Self::new(amount, unit, decimals)))
68    }
69
70    /// Whether two budgets count in the same unit at the same scale, so that
71    /// one can be spent from the other.
72    pub fn same_scale(&self, other: &Budget) -> bool {
73        self.unit == other.unit && self.decimals == other.decimals
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use serde_json::json;
81
82    #[test]
83    fn budgets_parse_strictly() {
84        for bad in [
85            json!({"amount": -1, "unit": "USD", "decimals": 2}),
86            json!({"amount": 1.5, "unit": "USD", "decimals": 2}),
87            json!({"amount": 1, "decimals": 2}),
88            json!({"amount": 1, "unit": "USD", "decimals": -2}),
89            json!({"amount": 9_007_199_254_740_992u64, "unit": "USD", "decimals": 2}),
90            json!("100 USD"),
91        ] {
92            assert!(Budget::from_value(Some(&bad)).is_err(), "{bad}");
93        }
94        assert!(Budget::from_value(Some(&Value::Null)).unwrap().is_none());
95    }
96
97    #[test]
98    fn only_the_same_unit_and_scale_compare() {
99        let usd = Budget::new(1, "USD", 2);
100        assert!(usd.same_scale(&Budget::new(5, "USD", 2)));
101        assert!(!usd.same_scale(&Budget::new(5, "USD", 0)));
102        assert!(!usd.same_scale(&Budget::new(5, "EUR", 2)));
103    }
104}