Skip to main content

df_hyperliquid_core/utils/
hex.rs

1#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
2pub enum BadHex {
3    #[error("hex must have an even number of digits, found {0}")]
4    OddLength(usize),
5    #[error("expected {expected} bytes of hex, found {found}")]
6    WrongLength { expected: usize, found: usize },
7    #[error("`{0}` is not a hex digit")]
8    NotAHexDigit(char),
9}
10
11#[must_use]
12pub fn encode(bytes: &[u8]) -> String {
13    let mut text = String::with_capacity(bytes.len() * 2);
14    for byte in bytes {
15        text.push(nibble(byte >> 4));
16        text.push(nibble(byte & 0x0f));
17    }
18    text
19}
20
21#[must_use]
22pub fn encode_prefixed(bytes: &[u8]) -> String {
23    let mut text = String::with_capacity(bytes.len() * 2 + 2);
24    text.push_str("0x");
25    text.push_str(&encode(bytes));
26    text
27}
28
29/// Decodes hex into a fresh vector, `0x` prefix and whitespace optional.
30pub fn decode(text: &str) -> Result<Vec<u8>, BadHex> {
31    let body = body_of(text);
32    if !body.len().is_multiple_of(2) {
33        return Err(BadHex::OddLength(body.len()));
34    }
35    let mut bytes = vec![0u8; body.len() / 2];
36    fill(body, &mut bytes)?;
37    Ok(bytes)
38}
39
40pub fn decode_into(text: &str, out: &mut [u8]) -> Result<(), BadHex> {
41    let body = body_of(text);
42    if !body.len().is_multiple_of(2) {
43        return Err(BadHex::OddLength(body.len()));
44    }
45    if body.len() / 2 != out.len() {
46        return Err(BadHex::WrongLength { expected: out.len(), found: body.len() / 2 });
47    }
48    fill(body, out)
49}
50
51pub fn decode_array<const N: usize>(text: &str) -> Result<[u8; N], BadHex> {
52    let mut bytes = [0u8; N];
53    decode_into(text, &mut bytes)?;
54    Ok(bytes)
55}
56
57fn body_of(text: &str) -> &str {
58    let raw = text.trim();
59    raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")).unwrap_or(raw)
60}
61
62fn fill(body: &str, out: &mut [u8]) -> Result<(), BadHex> {
63    let digits = body.as_bytes();
64    for &byte in digits {
65        digit(byte)?;
66    }
67    for (index, slot) in out.iter_mut().enumerate() {
68        let high = digit(digits[index * 2])?;
69        let low = digit(digits[index * 2 + 1])?;
70        *slot = (high << 4) | low;
71    }
72    Ok(())
73}
74
75fn digit(byte: u8) -> Result<u8, BadHex> {
76    match byte {
77        b'0'..=b'9' => Ok(byte - b'0'),
78        b'a'..=b'f' => Ok(byte - b'a' + 10),
79        b'A'..=b'F' => Ok(byte - b'A' + 10),
80        _ => Err(BadHex::NotAHexDigit(char::from(byte))),
81    }
82}
83
84const fn nibble(value: u8) -> char {
85    (if value < 10 { b'0' + value } else { b'a' + value - 10 }) as char
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn hex_is_lowercase_and_round_trips() {
94        assert_eq!(encode(b"hello world"), "68656c6c6f20776f726c64");
95        assert_eq!(encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
96        assert_eq!(encode_prefixed(&[0xde, 0xad, 0xbe, 0xef]), "0xdeadbeef");
97        assert_eq!(encode(&[]), "");
98        assert_eq!(encode_prefixed(&[]), "0x");
99        assert_eq!(decode("0xDEADBEEF").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
100        assert_eq!(decode("deadbeef").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
101        assert_eq!(decode("  0xdeadbeef\n").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
102    }
103
104    #[test]
105    fn a_fixed_size_decode_refuses_the_wrong_length() {
106        let bytes: [u8; 4] = decode_array("0xdeadbeef").unwrap();
107        assert_eq!(bytes, [0xde, 0xad, 0xbe, 0xef]);
108        assert_eq!(decode_array::<32>("0xdeadbeef"), Err(BadHex::WrongLength { expected: 32, found: 4 }));
109        assert_eq!(decode("0xabc"), Err(BadHex::OddLength(3)));
110        assert_eq!(decode("0xzz"), Err(BadHex::NotAHexDigit('z')));
111    }
112
113    #[test]
114    fn a_rejected_secret_is_never_quoted_back() {
115        let error = decode_array::<32>("0xnot-a-key").unwrap_err();
116        let text = error.to_string();
117        assert!(!text.contains("not-a-key"), "{text} leaked the input");
118    }
119}