xrpl/models/amount/
mod.rs1mod 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(MPTAmount<'a>),
26 IssuedCurrencyAmount(IssuedCurrencyAmount<'a>),
28 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 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 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 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 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 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 assert!(
186 value.is_finite(),
187 "NaN and Infinity cannot be converted to Amount; got {value}"
188 );
189 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 let xrp: f64 = 1.5;
269 let amount = Amount::from(xrp);
270
271 match amount {
273 Amount::XRPAmount(xrp_amount) => {
274 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 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 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 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 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 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 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 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 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}