Skip to main content

lichen_client_sdk/
types.rs

1//! Common types used in the SDK
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::str::FromStr;
6
7const SPORES_PER_LICN: u64 = 1_000_000_000;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum BalanceParseError {
11    Empty,
12    Negative,
13    InvalidFormat,
14    TooManyFractionalDigits,
15    Overflow,
16}
17
18impl fmt::Display for BalanceParseError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => write!(f, "LICN amount cannot be empty"),
22            Self::Negative => write!(f, "LICN amount cannot be negative"),
23            Self::InvalidFormat => write!(f, "LICN amount must be a decimal string"),
24            Self::TooManyFractionalDigits => {
25                write!(f, "LICN amount supports at most 9 fractional digits")
26            }
27            Self::Overflow => write!(f, "LICN amount exceeds supported range"),
28        }
29    }
30}
31
32impl std::error::Error for BalanceParseError {}
33
34/// Account balance
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Balance {
37    spores: u64,
38}
39
40impl Balance {
41    /// Create from spores
42    pub fn from_spores(spores: u64) -> Self {
43        Self { spores }
44    }
45
46    /// Create from a decimal LICN string without going through floating-point rounding.
47    pub fn from_licn(licn: &str) -> Result<Self, BalanceParseError> {
48        let licn = licn.trim();
49        if licn.is_empty() {
50            return Err(BalanceParseError::Empty);
51        }
52
53        if licn.starts_with('-') {
54            return Err(BalanceParseError::Negative);
55        }
56
57        let licn = licn.strip_prefix('+').unwrap_or(licn);
58        let mut parts = licn.split('.');
59        let whole_part = parts.next().unwrap_or_default();
60        let frac_part = parts.next();
61        if parts.next().is_some() {
62            return Err(BalanceParseError::InvalidFormat);
63        }
64
65        if whole_part.is_empty() && frac_part.unwrap_or_default().is_empty() {
66            return Err(BalanceParseError::InvalidFormat);
67        }
68
69        if !whole_part.chars().all(|ch| ch.is_ascii_digit()) {
70            return Err(BalanceParseError::InvalidFormat);
71        }
72
73        let whole_spores = if whole_part.is_empty() {
74            0
75        } else {
76            whole_part
77                .parse::<u64>()
78                .map_err(|_| BalanceParseError::Overflow)?
79                .checked_mul(SPORES_PER_LICN)
80                .ok_or(BalanceParseError::Overflow)?
81        };
82
83        let frac_spores = if let Some(frac_part) = frac_part {
84            if !frac_part.chars().all(|ch| ch.is_ascii_digit()) {
85                return Err(BalanceParseError::InvalidFormat);
86            }
87            if frac_part.len() > 9 {
88                return Err(BalanceParseError::TooManyFractionalDigits);
89            }
90            if frac_part.is_empty() {
91                0
92            } else {
93                let frac_digits = frac_part
94                    .parse::<u64>()
95                    .map_err(|_| BalanceParseError::Overflow)?;
96                frac_digits
97                    .checked_mul(10_u64.pow((9 - frac_part.len()) as u32))
98                    .ok_or(BalanceParseError::Overflow)?
99            }
100        } else {
101            0
102        };
103
104        Ok(Self {
105            spores: whole_spores
106                .checked_add(frac_spores)
107                .ok_or(BalanceParseError::Overflow)?,
108        })
109    }
110
111    pub fn from_licn_parts(
112        whole_licn: u64,
113        fractional_spores: u32,
114    ) -> Result<Self, BalanceParseError> {
115        if fractional_spores >= SPORES_PER_LICN as u32 {
116            return Err(BalanceParseError::TooManyFractionalDigits);
117        }
118
119        let whole_spores = whole_licn
120            .checked_mul(SPORES_PER_LICN)
121            .ok_or(BalanceParseError::Overflow)?;
122
123        Ok(Self {
124            spores: whole_spores
125                .checked_add(fractional_spores as u64)
126                .ok_or(BalanceParseError::Overflow)?,
127        })
128    }
129
130    /// Get spores
131    pub fn spores(&self) -> u64 {
132        self.spores
133    }
134
135    /// Get LICN
136    pub fn licn(&self) -> f64 {
137        self.spores as f64 / SPORES_PER_LICN as f64
138    }
139}
140
141impl FromStr for Balance {
142    type Err = BalanceParseError;
143
144    fn from_str(s: &str) -> Result<Self, Self::Err> {
145        Self::from_licn(s)
146    }
147}
148
149/// Block information
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Block {
152    pub hash: String,
153    pub parent_hash: String,
154    pub slot: u64,
155    pub state_root: String,
156    pub timestamp: u64,
157    pub transaction_count: u64,
158    pub validator: String,
159}
160
161/// Network information
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct NetworkInfo {
164    pub chain_id: String,
165    pub current_slot: u64,
166    pub network_id: String,
167    pub peer_count: u64,
168    pub validator_count: u64,
169    pub version: String,
170}
171
172/// Re-export transaction from core
173pub use lichen_core::Transaction;
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn test_balance_roundtrip() {
181        let b = Balance::from_spores(1_500_000_000);
182        assert_eq!(b.spores(), 1_500_000_000);
183        assert!((b.licn() - 1.5).abs() < 1e-9);
184    }
185
186    #[test]
187    fn test_balance_from_licn_normal() {
188        let b = Balance::from_licn("2.5").unwrap();
189        assert_eq!(b.spores(), 2_500_000_000);
190    }
191
192    #[test]
193    fn test_balance_from_licn_negative() {
194        assert_eq!(
195            Balance::from_licn("-1.0").unwrap_err(),
196            BalanceParseError::Negative
197        );
198    }
199
200    #[test]
201    fn test_balance_from_licn_invalid_format() {
202        assert_eq!(
203            Balance::from_licn("NaN").unwrap_err(),
204            BalanceParseError::InvalidFormat
205        );
206    }
207
208    #[test]
209    fn test_balance_from_licn_overflow() {
210        assert_eq!(
211            Balance::from_licn("18446744074").unwrap_err(),
212            BalanceParseError::Overflow
213        );
214    }
215
216    #[test]
217    fn test_balance_from_licn_zero() {
218        let b = Balance::from_licn("0").unwrap();
219        assert_eq!(b.spores(), 0);
220    }
221
222    #[test]
223    fn test_balance_empty_amount() {
224        assert_eq!(
225            Balance::from_licn("  ").unwrap_err(),
226            BalanceParseError::Empty
227        );
228    }
229
230    #[test]
231    fn test_balance_from_licn_tiny_fraction() {
232        let b = Balance::from_licn("0.000000001").unwrap();
233        assert_eq!(b.spores(), 1);
234    }
235
236    #[test]
237    fn test_balance_from_licn_sub_spore() {
238        assert_eq!(
239            Balance::from_licn("0.0000000001").unwrap_err(),
240            BalanceParseError::TooManyFractionalDigits
241        );
242    }
243
244    #[test]
245    fn test_balance_from_licn_leading_decimal() {
246        let b = Balance::from_licn(".5").unwrap();
247        assert_eq!(b.spores(), 500_000_000);
248    }
249
250    #[test]
251    fn test_balance_from_licn_trailing_decimal() {
252        let b = Balance::from_licn("1.").unwrap();
253        assert_eq!(b.spores(), 1_000_000_000);
254    }
255
256    #[test]
257    fn test_balance_from_licn_parts() {
258        let b = Balance::from_licn_parts(12, 345_000_000).unwrap();
259        assert_eq!(b.spores(), 12_345_000_000);
260    }
261
262    #[test]
263    fn test_balance_from_spores_max() {
264        let b = Balance::from_spores(u64::MAX);
265        assert_eq!(b.spores(), u64::MAX);
266    }
267
268    #[test]
269    fn test_balance_eq() {
270        let a = Balance::from_spores(100);
271        let b = Balance::from_spores(100);
272        assert_eq!(a, b);
273    }
274
275    #[test]
276    fn test_balance_copy() {
277        let a = Balance::from_spores(42);
278        let b = a;
279        assert_eq!(a.spores(), b.spores());
280    }
281
282    #[test]
283    fn test_balance_debug() {
284        let b = Balance::from_spores(0);
285        let s = format!("{:?}", b);
286        assert!(s.contains("Balance"));
287    }
288
289    #[test]
290    fn test_balance_licn_precision() {
291        // 1 LICN exactly
292        let b = Balance::from_licn("1.0").unwrap();
293        assert_eq!(b.spores(), 1_000_000_000);
294        assert!((b.licn() - 1.0).abs() < 1e-15);
295    }
296}