Skip to main content

df_hyperliquid_core/transfer/
amount.rs

1//! Amounts the venue is asked to move: plain decimals, at most eight places, never zero, in the
2//! normal form that is hashed (`"12.50"` is `"12.5"`, `"007"` is `"7"`).
3
4use std::fmt;
5
6use crate::utils::{Dec, Round};
7
8/// The most places an amount the venue takes may have.
9pub 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/// A validated amount: its wire string and the same value as a [`Dec`].
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Amount {
30    wire: String,
31    value: Dec,
32}
33
34impl Amount {
35    /// What a person typed, checked by [`clean_amount`].
36    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    /// A computed figure cut toward zero to `places` (at most eight), so it never asks for more
43    /// than is there.
44    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    /// The string that is posted and hashed.
56    #[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
73/// Checks a typed amount: digits, at most one point with digits before it, at most eight places,
74/// not zero. Returns the normal form; a place finer than eight is refused, never rounded.
75pub 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/// `value` cut toward zero to `places`, written with no exponent and no trailing zeros.
101#[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    /// The SDK's `formatDecimalString`: the hashed string is the normal one.
137    #[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}