df_hyperliquid_core/transfer/send/
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#[cfg(test)]
101mod tests {
102 use super::*;
103
104 fn dec(text: &str) -> Dec {
105 Dec::parse(text).unwrap()
106 }
107
108 #[test]
109 fn an_amount_is_checked_before_anything_is_signed() {
110 assert_eq!(clean_amount(" 100.5 ").unwrap(), "100.5");
111 assert_eq!(clean_amount("0.00000001").unwrap(), "0.00000001");
112 for (bad, why) in [
113 ("", BadAmount::Empty),
114 ("0", BadAmount::Zero),
115 ("0.00", BadAmount::Zero),
116 ("-5", BadAmount::Negative("-5".to_owned())),
117 ("1,000", BadAmount::NotDecimal("1,000".to_owned())),
118 ("1e5", BadAmount::NotDecimal("1e5".to_owned())),
119 ("abc", BadAmount::NotDecimal("abc".to_owned())),
120 ("1.000000001", BadAmount::TooFine("1.000000001".to_owned())),
121 (".", BadAmount::NotDecimal(".".to_owned())),
122 (".5", BadAmount::NotDecimal(".5".to_owned())),
123 ("+5", BadAmount::NotDecimal("+5".to_owned())),
124 ("1.2.3", BadAmount::NotDecimal("1.2.3".to_owned())),
125 ] {
126 assert_eq!(clean_amount(bad), Err(why), "`{bad}` should not be signable");
127 }
128 }
129
130 #[test]
132 fn an_amount_comes_back_in_the_form_the_sdk_hashes() {
133 for (typed, wire) in [
134 ("100", "100"),
135 ("100.0", "100"),
136 ("12.50", "12.5"),
137 ("000.5", "0.5"),
138 ("007", "7"),
139 ("1.", "1"),
140 ("1.000000000", "1"),
141 ("2550.12345678", "2550.12345678"),
142 ] {
143 assert_eq!(clean_amount(typed).unwrap(), wire, "{typed}");
144 assert_eq!(Amount::parse(typed).unwrap().as_str(), wire, "{typed}");
145 }
146 assert_eq!(Amount::parse("12.50").unwrap().value(), dec("12.5"));
147 }
148
149 #[test]
150 fn a_computed_figure_is_cut_toward_zero_never_rounded_up() {
151 assert_eq!(Amount::cut(dec("41085515.0539499968"), 6).unwrap().as_str(), "41085515.053949");
152 assert_eq!(Amount::cut(dec("0.123456789"), 12).unwrap().as_str(), "0.12345678", "eight places at most");
153 assert_eq!(Amount::cut(dec("0.0000009"), 6), Err(BadAmount::Zero), "dust cuts to nothing");
154 assert_eq!(Amount::cut(dec("-1"), 6), Err(BadAmount::Negative("-1".to_owned())));
155 }
156}