df_hyperliquid_core/transfer/
amount.rs1use std::fmt;
5
6use crate::utils::{Dec, Round};
7
8pub const MAX_DECIMALS: u32 = 8;
10
11#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
12pub enum BadAmount {
13 #[error("no amount given")]
14 Empty,
15 #[error("`{0}` is not a plain decimal amount")]
16 NotDecimal(String),
17 #[error("`{0}` has more than {MAX_DECIMALS} decimal places")]
18 TooFine(String),
19 #[error("the amount is zero")]
20 Zero,
21 #[error("`{0}` is below zero")]
22 Negative(String),
23 #[error("`{0}` is too large")]
24 TooLarge(String),
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Amount {
30 wire: String,
31 value: Dec,
32}
33
34impl Amount {
35 pub fn parse(text: &str) -> Result<Amount, BadAmount> {
37 let wire = clean_amount(text)?;
38 let value = Dec::parse(&wire).map_err(|_| BadAmount::TooLarge(wire.clone()))?;
39 Ok(Amount { wire, value })
40 }
41
42 pub fn cut(value: Dec, places: u32) -> Result<Amount, BadAmount> {
45 if value.is_negative() {
46 return Err(BadAmount::Negative(value.to_wire()));
47 }
48 let value = value.round(places.min(MAX_DECIMALS), Round::Down);
49 if value.is_zero() {
50 return Err(BadAmount::Zero);
51 }
52 Ok(Amount { wire: value.to_wire(), value })
53 }
54
55 #[must_use]
57 pub fn as_str(&self) -> &str {
58 &self.wire
59 }
60
61 #[must_use]
62 pub const fn value(&self) -> Dec {
63 self.value
64 }
65}
66
67impl fmt::Display for Amount {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 f.write_str(&self.wire)
70 }
71}
72
73pub fn clean_amount(text: &str) -> Result<String, BadAmount> {
76 let text = text.trim();
77 if text.is_empty() {
78 return Err(BadAmount::Empty);
79 }
80 if let Some(rest) = text.strip_prefix('-')
81 && !rest.is_empty()
82 && rest.bytes().all(|b| b.is_ascii_digit() || b == b'.')
83 {
84 return Err(BadAmount::Negative(text.to_owned()));
85 }
86 let (whole, fraction) = text.split_once('.').unwrap_or((text, ""));
87 if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) || !fraction.bytes().all(|b| b.is_ascii_digit()) {
88 return Err(BadAmount::NotDecimal(text.to_owned()));
89 }
90 if fraction.trim_end_matches('0').len() > MAX_DECIMALS as usize {
91 return Err(BadAmount::TooFine(text.to_owned()));
92 }
93 let value = Dec::parse(text).map_err(|_| BadAmount::TooLarge(text.to_owned()))?;
94 if value.is_zero() {
95 return Err(BadAmount::Zero);
96 }
97 Ok(value.to_wire())
98}
99
100#[must_use]
102pub fn cut(value: Dec, places: u32) -> String {
103 value.round(places, Round::Down).to_wire()
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 fn dec(text: &str) -> Dec {
111 Dec::parse(text).unwrap()
112 }
113
114 #[test]
115 fn an_amount_is_checked_before_anything_is_signed() {
116 assert_eq!(clean_amount(" 100.5 ").unwrap(), "100.5");
117 assert_eq!(clean_amount("0.00000001").unwrap(), "0.00000001");
118 for (bad, why) in [
119 ("", BadAmount::Empty),
120 ("0", BadAmount::Zero),
121 ("0.00", BadAmount::Zero),
122 ("-5", BadAmount::Negative("-5".to_owned())),
123 ("1,000", BadAmount::NotDecimal("1,000".to_owned())),
124 ("1e5", BadAmount::NotDecimal("1e5".to_owned())),
125 ("abc", BadAmount::NotDecimal("abc".to_owned())),
126 ("1.000000001", BadAmount::TooFine("1.000000001".to_owned())),
127 (".", BadAmount::NotDecimal(".".to_owned())),
128 (".5", BadAmount::NotDecimal(".5".to_owned())),
129 ("+5", BadAmount::NotDecimal("+5".to_owned())),
130 ("1.2.3", BadAmount::NotDecimal("1.2.3".to_owned())),
131 ] {
132 assert_eq!(clean_amount(bad), Err(why), "`{bad}` should not be signable");
133 }
134 }
135
136 #[test]
138 fn an_amount_comes_back_in_the_form_the_sdk_hashes() {
139 for (typed, wire) in [
140 ("100", "100"),
141 ("100.0", "100"),
142 ("12.50", "12.5"),
143 ("000.5", "0.5"),
144 ("007", "7"),
145 ("1.", "1"),
146 ("1.000000000", "1"),
147 ("2550.12345678", "2550.12345678"),
148 ] {
149 assert_eq!(clean_amount(typed).unwrap(), wire, "{typed}");
150 assert_eq!(Amount::parse(typed).unwrap().as_str(), wire, "{typed}");
151 }
152 assert_eq!(Amount::parse("12.50").unwrap().value(), dec("12.5"));
153 }
154
155 #[test]
156 fn a_computed_figure_is_cut_toward_zero_never_rounded_up() {
157 assert_eq!(cut(dec("52912.218999999"), 6), "52912.218999");
158 assert_eq!(cut(dec("1.9999999"), 0), "1");
159 assert_eq!(cut(dec("2550.0"), 6), "2550");
160 assert_eq!(Amount::cut(dec("41085515.0539499968"), 6).unwrap().as_str(), "41085515.053949");
161 assert_eq!(Amount::cut(dec("0.123456789"), 12).unwrap().as_str(), "0.12345678", "eight places at most");
162 assert_eq!(Amount::cut(dec("0.0000009"), 6), Err(BadAmount::Zero), "dust cuts to nothing");
163 assert_eq!(Amount::cut(dec("-1"), 6), Err(BadAmount::Negative("-1".to_owned())));
164 }
165}