Skip to main content

mina_sdk/
currency.rs

1use std::fmt;
2
3use crate::error::{Error, Result};
4
5/// 1 MINA = 10^9 nanomina.
6const NANOMINA_PER_MINA: u64 = 1_000_000_000;
7
8/// Represents a Mina currency amount stored internally as nanomina (atomic units).
9///
10/// # Examples
11/// ```
12/// use mina_sdk::Currency;
13///
14/// let one_mina = Currency::from_mina("1.5").unwrap();
15/// assert_eq!(one_mina.nanomina(), 1_500_000_000);
16/// assert_eq!(one_mina.mina(), "1.500000000");
17/// ```
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
19pub struct Currency(u64);
20
21impl Currency {
22    /// Create from a nanomina (atomic unit) value.
23    pub fn from_nanomina(nanomina: u64) -> Self {
24        Self(nanomina)
25    }
26
27    /// Create from a whole MINA decimal string (e.g. "1.5", "100", "0.000000001").
28    pub fn from_mina(s: &str) -> Result<Self> {
29        parse_decimal(s).map(Self)
30    }
31
32    /// Create from a GraphQL response value (nanomina as string).
33    pub fn from_graphql(s: &str) -> Result<Self> {
34        s.parse::<u64>()
35            .map(Self)
36            .map_err(|_| Error::InvalidCurrency(s.to_string()))
37    }
38
39    /// Get the value in nanomina (atomic units).
40    pub fn nanomina(&self) -> u64 {
41        self.0
42    }
43
44    /// Get the value as a MINA decimal string with 9 decimal places.
45    pub fn mina(&self) -> String {
46        let whole = self.0 / NANOMINA_PER_MINA;
47        let frac = self.0 % NANOMINA_PER_MINA;
48        format!("{whole}.{frac:09}")
49    }
50
51    /// Convert to nanomina string for GraphQL API submission.
52    pub fn to_nanomina_str(&self) -> String {
53        self.0.to_string()
54    }
55
56    /// Checked addition. Returns `None` on overflow.
57    pub fn checked_add(self, rhs: Currency) -> Option<Currency> {
58        self.0.checked_add(rhs.0).map(Currency)
59    }
60
61    /// Checked subtraction. Returns `Err(CurrencyUnderflow)` if result would be negative.
62    pub fn checked_sub(self, rhs: Currency) -> Result<Currency> {
63        self.0
64            .checked_sub(rhs.0)
65            .map(Currency)
66            .ok_or(Error::CurrencyUnderflow(self.0, rhs.0))
67    }
68
69    /// Multiply by a scalar.
70    pub fn checked_mul(self, rhs: u64) -> Option<Currency> {
71        self.0.checked_mul(rhs).map(Currency)
72    }
73}
74
75impl fmt::Display for Currency {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "{}", self.mina())
78    }
79}
80
81impl std::ops::Add for Currency {
82    type Output = Currency;
83    fn add(self, rhs: Self) -> Self::Output {
84        Currency(self.0 + rhs.0)
85    }
86}
87
88impl std::ops::Sub for Currency {
89    type Output = Currency;
90    /// Panics on underflow. Use `checked_sub` for fallible version.
91    fn sub(self, rhs: Self) -> Self::Output {
92        Currency(self.0.checked_sub(rhs.0).expect("currency underflow"))
93    }
94}
95
96impl std::ops::Mul<u64> for Currency {
97    type Output = Currency;
98    fn mul(self, rhs: u64) -> Self::Output {
99        Currency(self.0 * rhs)
100    }
101}
102
103impl std::ops::Mul<Currency> for u64 {
104    type Output = Currency;
105    fn mul(self, rhs: Currency) -> Self::Output {
106        Currency(self * rhs.0)
107    }
108}
109
110/// Parse a decimal string like "1.5" or "100" into nanomina.
111fn parse_decimal(s: &str) -> Result<u64> {
112    let s = s.trim();
113    if s.is_empty() {
114        return Err(Error::InvalidCurrency(s.to_string()));
115    }
116
117    let (whole_str, frac_str) = match s.split_once('.') {
118        Some((w, f)) => (w, f),
119        None => (s, ""),
120    };
121
122    let whole: u64 = if whole_str.is_empty() {
123        0
124    } else {
125        whole_str
126            .parse()
127            .map_err(|_| Error::InvalidCurrency(s.to_string()))?
128    };
129
130    if frac_str.len() > 9 {
131        return Err(Error::InvalidCurrency(format!(
132            "too many decimal places (max 9): {s}"
133        )));
134    }
135
136    let frac: u64 = if frac_str.is_empty() {
137        0
138    } else {
139        let padded = format!("{frac_str:0<9}");
140        padded
141            .parse()
142            .map_err(|_| Error::InvalidCurrency(s.to_string()))?
143    };
144
145    whole
146        .checked_mul(NANOMINA_PER_MINA)
147        .and_then(|w| w.checked_add(frac))
148        .ok_or_else(|| Error::InvalidCurrency(format!("overflow: {s}")))
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn from_mina_integer() {
157        let c = Currency::from_mina("5").unwrap();
158        assert_eq!(c.nanomina(), 5_000_000_000);
159    }
160
161    #[test]
162    fn from_mina_decimal() {
163        let c = Currency::from_mina("1.5").unwrap();
164        assert_eq!(c.nanomina(), 1_500_000_000);
165    }
166
167    #[test]
168    fn from_mina_small() {
169        let c = Currency::from_mina("0.000000001").unwrap();
170        assert_eq!(c.nanomina(), 1);
171    }
172
173    #[test]
174    fn from_mina_no_whole() {
175        let c = Currency::from_mina(".5").unwrap();
176        assert_eq!(c.nanomina(), 500_000_000);
177    }
178
179    #[test]
180    fn from_graphql() {
181        let c = Currency::from_graphql("1500000000").unwrap();
182        assert_eq!(c.nanomina(), 1_500_000_000);
183        assert_eq!(c.mina(), "1.500000000");
184    }
185
186    #[test]
187    fn to_nanomina_str() {
188        let c = Currency::from_mina("3").unwrap();
189        assert_eq!(c.to_nanomina_str(), "3000000000");
190    }
191
192    #[test]
193    fn display() {
194        let c = Currency::from_nanomina(1);
195        assert_eq!(c.to_string(), "0.000000001");
196        assert_eq!(format!("{c}"), "0.000000001");
197    }
198
199    #[test]
200    fn addition() {
201        let a = Currency::from_mina("1").unwrap();
202        let b = Currency::from_mina("2").unwrap();
203        assert_eq!((a + b).nanomina(), 3_000_000_000);
204    }
205
206    #[test]
207    fn subtraction() {
208        let a = Currency::from_mina("3").unwrap();
209        let b = Currency::from_mina("1").unwrap();
210        assert_eq!((a - b).nanomina(), 2_000_000_000);
211    }
212
213    #[test]
214    fn checked_sub_underflow() {
215        let a = Currency::from_mina("1").unwrap();
216        let b = Currency::from_mina("2").unwrap();
217        assert!(a.checked_sub(b).is_err());
218    }
219
220    #[test]
221    fn multiplication() {
222        let c = Currency::from_mina("2").unwrap();
223        assert_eq!((c * 3).nanomina(), 6_000_000_000);
224    }
225
226    #[test]
227    fn reverse_multiplication() {
228        let c = Currency::from_mina("2").unwrap();
229        assert_eq!((3_u64 * c).nanomina(), 6_000_000_000);
230    }
231
232    #[test]
233    fn ordering() {
234        let a = Currency::from_mina("1").unwrap();
235        let b = Currency::from_mina("2").unwrap();
236        assert!(a < b);
237        assert!(b > a);
238        assert!(a <= a);
239        assert!(a >= a);
240    }
241
242    #[test]
243    fn hash_consistency() {
244        use std::collections::HashSet;
245        let a = Currency::from_mina("1").unwrap();
246        let b = Currency::from_nanomina(1_000_000_000);
247        let mut set = HashSet::new();
248        set.insert(a);
249        set.insert(b);
250        assert_eq!(set.len(), 1);
251    }
252
253    #[test]
254    fn from_mina_no_decimal() {
255        let c = Currency::from_mina("100").unwrap();
256        assert_eq!(c.nanomina(), 100_000_000_000);
257    }
258
259    #[test]
260    fn from_nanomina_explicit() {
261        let c = Currency::from_nanomina(500_000_000);
262        assert_eq!(c.mina(), "0.500000000");
263        assert_eq!(c.nanomina(), 500_000_000);
264    }
265
266    #[test]
267    fn small_nanomina_display() {
268        let c = Currency::from_nanomina(1);
269        assert_eq!(c.mina(), "0.000000001");
270    }
271
272    #[test]
273    fn zero_currency() {
274        let c = Currency::from_nanomina(0);
275        assert_eq!(c.mina(), "0.000000000");
276        assert_eq!(c.to_nanomina_str(), "0");
277    }
278
279    #[test]
280    fn checked_add_basic() {
281        let a = Currency::from_mina("1").unwrap();
282        let b = Currency::from_mina("2").unwrap();
283        assert_eq!(a.checked_add(b).unwrap().nanomina(), 3_000_000_000);
284    }
285
286    #[test]
287    fn checked_mul_basic() {
288        let c = Currency::from_mina("2").unwrap();
289        assert_eq!(c.checked_mul(3).unwrap().nanomina(), 6_000_000_000);
290    }
291
292    #[test]
293    fn equality_across_constructors() {
294        let a = Currency::from_mina("1").unwrap();
295        let b = Currency::from_nanomina(1_000_000_000);
296        let c = Currency::from_graphql("1000000000").unwrap();
297        assert_eq!(a, b);
298        assert_eq!(b, c);
299    }
300
301    #[test]
302    fn too_many_decimals() {
303        assert!(Currency::from_mina("1.0000000001").is_err());
304    }
305
306    #[test]
307    fn invalid_format() {
308        assert!(Currency::from_mina("abc").is_err());
309        assert!(Currency::from_mina("").is_err());
310        assert!(Currency::from_graphql("not_a_number").is_err());
311    }
312
313    #[test]
314    fn negative_input_rejected() {
315        assert!(Currency::from_mina("-1").is_err());
316        assert!(Currency::from_graphql("-500").is_err());
317    }
318}