Skip to main content

paralock_cli/
amount.rs

1//! Exact decimal amounts: ZEC ↔ zatoshi and dollars ↔ token base units, without floating point.
2
3use crate::error::{CliError, CliResult};
4
5/// Zatoshi per ZEC.
6pub const ZAT_PER_ZEC: u64 = 100_000_000;
7
8/// Parses a non-negative decimal (`"0.1"`, `"25"`, `"1.000001"`) into base units with `decimals` digits. Refuses
9/// signs, exponents, empty parts and more fractional digits than `decimals`.
10pub fn parse_decimal(text: &str, decimals: u32, what: &str) -> CliResult<u128> {
11    let bad = || {
12        CliError::usage(format!(
13            "{what} must be a positive decimal number with at most {decimals} decimals, got \"{text}\""
14        ))
15    };
16    let text = text.trim();
17    let (whole, frac) = text.split_once('.').unwrap_or((text, ""));
18    if (whole.is_empty() && frac.is_empty())
19        || !whole.bytes().all(|b| b.is_ascii_digit())
20        || !frac.bytes().all(|b| b.is_ascii_digit())
21        || frac.len() > decimals as usize
22        || (text.contains('.') && frac.is_empty())
23    {
24        return Err(bad());
25    }
26    let scale = 10u128.checked_pow(decimals).ok_or_else(bad)?;
27    let whole: u128 = if whole.is_empty() { 0 } else { whole.parse().map_err(|_| bad())? };
28    let frac_units: u128 = if frac.is_empty() {
29        0
30    } else {
31        let padded = format!("{frac:0<width$}", width = decimals as usize);
32        padded.parse().map_err(|_| bad())?
33    };
34    whole.checked_mul(scale).and_then(|w| w.checked_add(frac_units)).ok_or_else(bad)
35}
36
37/// Parses an amount of ZEC into zatoshi (more than zero).
38pub fn parse_zec(text: &str) -> CliResult<u64> {
39    let zat = parse_decimal(text, 8, "--zec")?;
40    let zat = u64::try_from(zat).map_err(|_| CliError::usage("--zec is too large"))?;
41    if zat == 0 {
42        return Err(CliError::usage("--zec must be more than 0"));
43    }
44    Ok(zat)
45}
46
47/// Parses an amount of dollars into token base units with `decimals` digits (more than zero).
48pub fn parse_usd(text: &str, decimals: u32) -> CliResult<u128> {
49    let units = parse_decimal(text, decimals, "--usd")?;
50    if units == 0 {
51        return Err(CliError::usage("--usd must be more than 0"));
52    }
53    Ok(units)
54}
55
56/// Zatoshi as ZEC, without trailing zeros (`10000000` → `"0.1"`).
57pub fn zec(zat: u64) -> String {
58    let (whole, frac) = (zat / ZAT_PER_ZEC, zat % ZAT_PER_ZEC);
59    if frac == 0 { whole.to_string() } else { format!("{whole}.{}", format!("{frac:08}").trim_end_matches('0')) }
60}
61
62/// Token base units as dollars with two decimals, rounded down (`4975000` → `"4.97"` for 6 decimals).
63pub fn usd(units: u128, decimals: u32) -> String {
64    let scale = 10u128.saturating_pow(decimals).max(1);
65    let cents = units.saturating_mul(100) / scale;
66    format!("{}.{:02}", cents / 100, cents % 100)
67}
68
69/// Token base units as an exact decimal string (`4975000` → `"4.975"` for 6 decimals).
70pub fn exact(units: u128, decimals: u32) -> String {
71    let scale = 10u128.saturating_pow(decimals).max(1);
72    let (whole, frac) = (units / scale, units % scale);
73    if frac == 0 {
74        whole.to_string()
75    } else {
76        let digits = format!("{frac:0width$}", width = decimals as usize);
77        format!("{whole}.{}", digits.trim_end_matches('0'))
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
84    use super::*;
85
86    #[test]
87    fn parses_exact_decimals() {
88        assert_eq!(parse_zec("0.1").unwrap(), 10_000_000);
89        assert_eq!(parse_zec("1").unwrap(), 100_000_000);
90        assert_eq!(parse_zec(".5").unwrap(), 50_000_000);
91        assert_eq!(parse_zec("0.00000001").unwrap(), 1);
92        assert_eq!(parse_usd("25", 6).unwrap(), 25_000_000);
93        assert_eq!(parse_usd("0.000001", 6).unwrap(), 1);
94        for bad in ["", ".", "1.", "-1", "1e3", "0.000000001", "1,5", " ", "0", "0.0", "abc", "1.2.3", "+1"] {
95            assert!(parse_zec(bad).is_err(), "{bad}");
96        }
97        assert!(parse_usd("1.0000001", 6).is_err());
98        assert!(parse_decimal("340282366920938463463374607431768211456", 0, "x").is_err());
99    }
100
101    #[test]
102    fn formats_amounts() {
103        assert_eq!(zec(10_000_000), "0.1");
104        assert_eq!(zec(123_456_789), "1.23456789");
105        assert_eq!(zec(500_000_000), "5");
106        assert_eq!(usd(4_975_000, 6), "4.97");
107        assert_eq!(usd(5, 6), "0.00");
108        assert_eq!(exact(4_975_000, 6), "4.975");
109        assert_eq!(exact(25_000_000, 6), "25");
110    }
111}