Skip to main content

xrpl/models/amount/
mod.rs

1mod issued_currency_amount;
2mod mpt_amount;
3mod xrp_amount;
4
5pub use issued_currency_amount::*;
6pub use mpt_amount::*;
7pub use xrp_amount::*;
8
9use alloc::string::ToString;
10use core::convert::TryInto;
11use core::str::FromStr;
12
13use bigdecimal::BigDecimal;
14use serde::{Deserialize, Deserializer, Serialize};
15use strum_macros::Display;
16
17use crate::{models::Model, utils::XRP_DROPS};
18
19use super::{XRPLModelException, XRPLModelResult};
20
21#[derive(Debug, PartialEq, Eq, Clone, Serialize, Display)]
22#[serde(untagged)]
23pub enum Amount<'a> {
24    // MPTAmount must be tried first: object with `mpt_issuance_id` key (no currency/issuer)
25    MPTAmount(MPTAmount<'a>),
26    // IssuedCurrencyAmount must be tried next (requires currency, issuer, value)
27    IssuedCurrencyAmount(IssuedCurrencyAmount<'a>),
28    // XRPAmount must be tried last (can be string or number)
29    XRPAmount(XRPAmount<'a>),
30}
31
32impl<'de, 'a> Deserialize<'de> for Amount<'a> {
33    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
34    where
35        D: Deserializer<'de>,
36    {
37        let value = serde_json::Value::deserialize(deserializer)?;
38
39        if let Some(obj) = value.as_object() {
40            // MPT amount: exactly the two keys {"mpt_issuance_id", "value"} — matches
41            // xrpl.js isAmountObjectMPT which requires sorted keys === ["mpt_issuance_id","value"].
42            // Any extra key (e.g. a hybrid object) falls through to the ICA path.
43            if obj.len() == 2 && obj.contains_key("mpt_issuance_id") && obj.contains_key("value") {
44                if let Ok(mpt) = serde_json::from_value::<MPTAmount>(value.clone()) {
45                    return Ok(Amount::MPTAmount(mpt));
46                }
47            }
48
49            // ICA: exactly the three keys {"currency", "issuer", "value"}.
50            if obj.len() == 3
51                && obj.contains_key("currency")
52                && obj.contains_key("issuer")
53                && obj.contains_key("value")
54            {
55                if let Ok(issued) = serde_json::from_value::<IssuedCurrencyAmount>(value.clone()) {
56                    return Ok(Amount::IssuedCurrencyAmount(issued));
57                }
58            }
59        }
60
61        // If it's a string or number, try XRPAmount
62        if value.is_string() || value.is_number() {
63            if let Ok(xrp) = serde_json::from_value::<XRPAmount>(value.clone()) {
64                return Ok(Amount::XRPAmount(xrp));
65            }
66        }
67
68        Err(serde::de::Error::custom(
69            "Amount must be a string/number (for XRP), an object with currency/issuer/value (for IssuedCurrency), or an object with mpt_issuance_id/value (for MPT)"
70        ))
71    }
72}
73
74impl<'a> TryInto<BigDecimal> for Amount<'a> {
75    type Error = XRPLModelException;
76
77    fn try_into(self) -> XRPLModelResult<BigDecimal, Self::Error> {
78        match self {
79            Amount::MPTAmount(amount) => {
80                // Match MPTAmount validation: unsigned decimal digits only, then enforce
81                // the XLS-33 / rippled limit of i64::MAX = 9223372036854775807.
82                if amount.value.is_empty() || !amount.value.bytes().all(|b| b.is_ascii_digit()) {
83                    return Err(XRPLModelException::InvalidValue {
84                        field: "value".into(),
85                        expected: "unsigned decimal string".into(),
86                        found: amount.value.to_string(),
87                    });
88                }
89                let n: u64 =
90                    amount
91                        .value
92                        .parse()
93                        .map_err(|_| XRPLModelException::InvalidValue {
94                            field: "value".into(),
95                            expected: "unsigned decimal string".into(),
96                            found: amount.value.to_string(),
97                        })?;
98                if n > i64::MAX as u64 {
99                    return Err(XRPLModelException::InvalidValue {
100                        field: "value".into(),
101                        expected: alloc::format!("MPT amount <= {} (i64::MAX)", i64::MAX),
102                        found: amount.value.to_string(),
103                    });
104                }
105                Ok(BigDecimal::from(n))
106            }
107            Amount::IssuedCurrencyAmount(amount) => amount.try_into(),
108            Amount::XRPAmount(amount) => amount.try_into(),
109        }
110    }
111}
112
113impl<'a> Model for Amount<'a> {
114    fn get_errors(&self) -> XRPLModelResult<()> {
115        match self {
116            Amount::MPTAmount(amount) => amount.get_errors(),
117            Amount::IssuedCurrencyAmount(amount) => amount.get_errors(),
118            Amount::XRPAmount(amount) => amount.get_errors(),
119        }
120    }
121}
122
123impl<'a> Default for Amount<'a> {
124    fn default() -> Self {
125        Self::XRPAmount("0".into())
126    }
127}
128
129impl<'a> Amount<'a> {
130    pub fn is_xrp(&self) -> bool {
131        matches!(self, Amount::XRPAmount(_))
132    }
133
134    /// Returns `true` only for `IssuedCurrencyAmount`. MPT amounts return `false`.
135    /// **Breaking change from pre-MPT behaviour:** previously this returned `!is_xrp()`,
136    /// so callers treating it as "not XRP" must now also check `is_mpt()`.
137    pub fn is_issued_currency(&self) -> bool {
138        matches!(self, Amount::IssuedCurrencyAmount(_))
139    }
140
141    pub fn is_mpt(&self) -> bool {
142        matches!(self, Amount::MPTAmount(_))
143    }
144}
145
146impl<'a> From<MPTAmount<'a>> for Amount<'a> {
147    fn from(value: MPTAmount<'a>) -> Self {
148        Self::MPTAmount(value)
149    }
150}
151
152impl<'a> From<IssuedCurrencyAmount<'a>> for Amount<'a> {
153    fn from(value: IssuedCurrencyAmount<'a>) -> Self {
154        Self::IssuedCurrencyAmount(value)
155    }
156}
157
158impl<'a> From<XRPAmount<'a>> for Amount<'a> {
159    fn from(value: XRPAmount<'a>) -> Self {
160        Self::XRPAmount(value)
161    }
162}
163
164impl<'a> From<&'a str> for Amount<'a> {
165    fn from(value: &'a str) -> Self {
166        Self::XRPAmount(value.into())
167    }
168}
169
170impl<'a> From<u32> for Amount<'a> {
171    fn from(value: u32) -> Self {
172        Self::XRPAmount(value.to_string().into())
173    }
174}
175
176impl<'a> From<u64> for Amount<'a> {
177    fn from(value: u64) -> Self {
178        Self::XRPAmount(value.to_string().into())
179    }
180}
181
182impl<'a> From<f64> for Amount<'a> {
183    fn from(value: f64) -> Self {
184        // NaN and Infinity have no meaningful drops representation — treat as a programming error.
185        assert!(
186            value.is_finite(),
187            "NaN and Infinity cannot be converted to Amount; got {value}"
188        );
189        // Use BigDecimal for fixed-point arithmetic to avoid floating-point precision loss
190        // Convert f64 to string first to preserve exact decimal representation
191        let value_bd =
192            BigDecimal::from_str(&value.to_string()).unwrap_or_else(|_| BigDecimal::from(0));
193        let drops_bd = BigDecimal::from(XRP_DROPS);
194        let result = value_bd * drops_bd;
195
196        Self::XRPAmount(result.normalized().to_string().into())
197    }
198}
199
200impl<'a> From<BigDecimal> for Amount<'a> {
201    fn from(value: BigDecimal) -> Self {
202        Self::XRPAmount((value * XRP_DROPS).normalized().to_string().into())
203    }
204}
205
206#[cfg(test)]
207mod tests_amount_enum {
208    use super::*;
209
210    #[test]
211    fn test_amount_deserialize_valid_xrp_string() {
212        let json = "\"100\"";
213        let amount: Result<Amount, _> = serde_json::from_str(json);
214        assert!(amount.is_ok());
215        assert!(amount.unwrap().is_xrp());
216    }
217
218    #[test]
219    fn test_amount_deserialize_valid_issued_currency() {
220        let json =
221            r#"{"currency":"USD","issuer":"rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd","value":"100"}"#;
222        let amount: Result<Amount, _> = serde_json::from_str(json);
223        assert!(amount.is_ok());
224        assert!(amount.unwrap().is_issued_currency());
225    }
226
227    #[test]
228    fn test_amount_deserialize_malformed_object_should_fail() {
229        let json = r#"{"invalid":"object"}"#;
230        let amount: Result<Amount, _> = serde_json::from_str(json);
231        assert!(
232            amount.is_err(),
233            "Malformed object should not deserialize silently"
234        );
235    }
236
237    #[test]
238    fn test_amount_deserialize_empty_object_should_fail() {
239        let json = "{}";
240        let amount: Result<Amount, _> = serde_json::from_str(json);
241        assert!(
242            amount.is_err(),
243            "Empty object should not deserialize silently"
244        );
245    }
246
247    #[test]
248    fn test_amount_deserialize_partial_issued_currency_should_fail() {
249        let json = r#"{"currency":"USD"}"#;
250        let amount: Result<Amount, _> = serde_json::from_str(json);
251        assert!(
252            amount.is_err(),
253            "Partial IssuedCurrency (missing issuer/value) should fail"
254        );
255    }
256
257    #[test]
258    fn test_amount_deserialize_null_should_fail() {
259        let json = "null";
260        let amount: Result<Amount, _> = serde_json::from_str(json);
261        assert!(amount.is_err(), "Null should not deserialize");
262    }
263
264    #[test]
265    fn test_amount_from_f64_preserves_precision() {
266        // Test that f64 conversion uses fixed-point arithmetic, not floating-point
267        // 1.5 XRP should convert to 1_500_000 drops exactly (no rounding errors)
268        let xrp: f64 = 1.5;
269        let amount = Amount::from(xrp);
270
271        // Extract as string to check exact value
272        match amount {
273            Amount::XRPAmount(xrp_amount) => {
274                // 1.5 * 1_000_000 = 1_500_000
275                // Using BigDecimal should give exact result
276                let value_str = xrp_amount.0.to_string();
277                assert!(
278                    value_str == "1500000" || value_str.contains("1500000"),
279                    "Expected 1500000 drops from 1.5 XRP, got: {}",
280                    value_str
281                );
282            }
283            _ => panic!("Expected XRPAmount variant"),
284        }
285    }
286
287    #[test]
288    fn test_amount_from_f64_with_small_value() {
289        // Test with a small value: 0.001 XRP = 1000 drops
290        let xrp: f64 = 0.001;
291        let amount = Amount::from(xrp);
292
293        match amount {
294            Amount::XRPAmount(xrp_amount) => {
295                let value_str = xrp_amount.0.to_string();
296                assert!(
297                    value_str == "1000" || value_str.contains("1000"),
298                    "Expected 1000 drops from 0.001 XRP, got: {}",
299                    value_str
300                );
301            }
302            _ => panic!("Expected XRPAmount variant"),
303        }
304    }
305
306    const MPT_ID: &str = "00000001A407AF5856CEFBF81F3D4A0000000000A407AF58";
307
308    #[test]
309    fn test_amount_deserialize_valid_mpt() {
310        let json = r#"{"value":"100","mpt_issuance_id":"00000001A407AF5856CEFBF81F3D4A0000000000A407AF58"}"#;
311        let amount: Amount = serde_json::from_str(json).unwrap();
312        assert!(amount.is_mpt());
313        assert!(!amount.is_xrp());
314        assert!(!amount.is_issued_currency());
315    }
316
317    #[test]
318    fn test_amount_mpt_json_round_trip() {
319        let original = Amount::MPTAmount(MPTAmount::new("42".into(), MPT_ID.into()));
320        let json = serde_json::to_string(&original).unwrap();
321        let decoded: Amount = serde_json::from_str(&json).unwrap();
322        assert_eq!(original, decoded);
323    }
324
325    #[test]
326    fn test_amount_mpt_not_confused_with_issued_currency() {
327        // An IssuedCurrency object must NOT be parsed as MPTAmount
328        let json =
329            r#"{"currency":"USD","issuer":"rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd","value":"100"}"#;
330        let amount: Amount = serde_json::from_str(json).unwrap();
331        assert!(amount.is_issued_currency() && !amount.is_mpt());
332    }
333
334    #[test]
335    fn test_amount_mpt_hybrid_object_rejected() {
336        // xrpl.js isAmountObjectMPT requires exactly 2 keys {"mpt_issuance_id","value"};
337        // an IOU-like isAmountObjectIOU requires exactly 3 keys {"currency","issuer","value"}.
338        // A 4-key hybrid satisfies neither guard and must be rejected by both.
339        let json = r#"{"mpt_issuance_id":"00000001A407AF5856CEFBF81F3D4A0000000000A407AF58","currency":"USD","issuer":"rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd","value":"100"}"#;
340        let result: Result<Amount, _> = serde_json::from_str(json);
341        assert!(
342            result.is_err(),
343            "hybrid 4-key object must be rejected (matches neither MPT nor ICA exact key-sets)"
344        );
345    }
346
347    #[test]
348    fn test_amount_mpt_extra_key_rejected() {
349        // Object with correct MPT keys plus an unexpected extra field must be rejected.
350        // xrpl.js rejects any object where sorted keys != ["mpt_issuance_id","value"].
351        let json = r#"{"mpt_issuance_id":"00000001A407AF5856CEFBF81F3D4A0000000000A407AF58","value":"100","foo":"bar"}"#;
352        let result: Result<Amount, _> = serde_json::from_str(json);
353        assert!(
354            result.is_err(),
355            "3-key MPT-like object with extra field must be rejected"
356        );
357    }
358
359    #[test]
360    fn test_amount_mpt_exact_keys_accepted() {
361        // Exactly {"mpt_issuance_id","value"} — the only valid MPT shape.
362        let json = r#"{"mpt_issuance_id":"00000001A407AF5856CEFBF81F3D4A0000000000A407AF58","value":"100"}"#;
363        let amount: Amount = serde_json::from_str(json).unwrap();
364        assert!(amount.is_mpt(), "exact 2-key MPT object must parse as MPT");
365        assert!(!amount.is_issued_currency());
366    }
367
368    #[test]
369    fn test_amount_ica_extra_key_rejected() {
370        // Object with correct ICA keys plus an extra field must be rejected.
371        // xrpl.js isAmountObjectIOU requires exactly 3 keys.
372        let json = r#"{"currency":"USD","issuer":"rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd","value":"100","extra":"field"}"#;
373        let result: Result<Amount, _> = serde_json::from_str(json);
374        assert!(
375            result.is_err(),
376            "4-key ICA-like object with extra field must be rejected"
377        );
378    }
379
380    #[test]
381    fn test_amount_mpt_try_into_bigdecimal_enforces_i64_max() {
382        use core::convert::TryInto;
383        // Value exceeding i64::MAX must fail at TryInto<BigDecimal>, not silently succeed.
384        // This value is i64::MAX + 1. It fits in a u64, but is an invalid MPT amount.
385        let oversized =
386            Amount::MPTAmount(MPTAmount::new("9223372036854775808".into(), MPT_ID.into()));
387        let result: Result<bigdecimal::BigDecimal, _> = oversized.try_into();
388        assert!(
389            result.is_err(),
390            "value > i64::MAX must be rejected: {}",
391            i64::MAX
392        );
393
394        // A value > u64::MAX should also fail (at the parse<u64> step)
395        let overflowing =
396            Amount::MPTAmount(MPTAmount::new("99999999999999999999".into(), MPT_ID.into()));
397        let result: Result<bigdecimal::BigDecimal, _> = overflowing.try_into();
398        assert!(result.is_err(), "value > u64::MAX must be rejected");
399    }
400
401    #[test]
402    #[should_panic(expected = "NaN and Infinity cannot be converted to Amount; got NaN")]
403    fn test_amount_from_f64_panics_on_nan() {
404        let _ = Amount::from(f64::NAN);
405    }
406
407    #[test]
408    #[should_panic(expected = "NaN and Infinity cannot be converted to Amount; got inf")]
409    fn test_amount_from_f64_panics_on_infinity() {
410        let _ = Amount::from(f64::INFINITY);
411    }
412}