Skip to main content

df_hyperliquid_core/utils/sign/eip712/
wallet.rs

1//! Wallet typed data hashed from its JSON alone, sharing nothing with how it was written but keccak.
2
3use crate::utils::hex;
4use crate::utils::json::{BadJson, Json};
5use crate::utils::sign::eip712::word::{address_word, decimal_word, word};
6use crate::utils::sign::keccak::{Keccak256, keccak256};
7
8/// Why wallet typed data could not be hashed.
9#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
10pub enum BadTypedData {
11    #[error("not JSON: {0}")]
12    Json(#[from] BadJson),
13    #[error("{0}")]
14    Shape(String),
15}
16
17/// What a wallet hashes for `eth_signTypedData_v4` JSON, read from the JSON
18/// alone: the type strings from its `types` lists, the values from `domain`
19/// and `message`. A cross-check of
20/// [`TypedStruct::wallet_json`](super::TypedStruct::wallet_json) that shares
21/// nothing with it but keccak. Flat structs and the types used here only.
22pub fn digest_of_wallet_json(text: &str) -> Result<[u8; 32], BadTypedData> {
23    let shape = |what: &str| BadTypedData::Shape(what.to_owned());
24    let json = Json::parse(text)?;
25    let types = json.get("types").ok_or_else(|| shape("no `types`"))?;
26    let primary = json.get("primaryType").and_then(Json::as_str).ok_or_else(|| shape("no `primaryType`"))?;
27    let list = |name: &str| {
28        types.get(name).and_then(Json::as_array).ok_or_else(|| BadTypedData::Shape(format!("no type `{name}`")))
29    };
30    let domain = json.get("domain").ok_or_else(|| shape("no `domain`"))?;
31    let message = json.get("message").ok_or_else(|| shape("no `message`"))?;
32    let separator = struct_hash("EIP712Domain", list("EIP712Domain")?, domain)?;
33    let data = struct_hash(primary, list(primary)?, message)?;
34    Ok(Keccak256::new().chain(&[0x19, 0x01]).chain(&separator).chain(&data).finalize())
35}
36
37fn struct_hash(name: &str, fields: &[Json], values: &Json) -> Result<[u8; 32], BadTypedData> {
38    let mut signature = format!("{name}(");
39    let mut words = Vec::with_capacity(fields.len());
40    for (index, field) in fields.iter().enumerate() {
41        let (Some(field_name), Some(kind)) =
42            (field.get("name").and_then(Json::as_str), field.get("type").and_then(Json::as_str))
43        else {
44            return Err(BadTypedData::Shape(format!("a field of `{name}` without a name and a type")));
45        };
46        if index > 0 {
47            signature.push(',');
48        }
49        signature.push_str(kind);
50        signature.push(' ');
51        signature.push_str(field_name);
52        let value = values
53            .get(field_name)
54            .ok_or_else(|| BadTypedData::Shape(format!("`{name}` has no value for `{field_name}`")))?;
55        let word = encode_json(kind, value)
56            .ok_or_else(|| BadTypedData::Shape(format!("`{field_name}` is not a `{kind}`: {}", value.compact())))?;
57        words.push(word);
58    }
59    signature.push(')');
60    let mut sponge = Keccak256::new().chain(&keccak256(signature.as_bytes()));
61    for word in &words {
62        sponge = sponge.chain(word);
63    }
64    Ok(sponge.finalize())
65}
66
67fn encode_json(kind: &str, value: &Json) -> Option<[u8; 32]> {
68    match (kind, value) {
69        ("string", Json::String(text)) => Some(keccak256(text.as_bytes())),
70        ("address", Json::String(text)) => text.parse().ok().map(address_word),
71        ("bool", Json::Bool(flag)) => Some(word(u64::from(*flag))),
72        ("bytes32", Json::String(text)) => hex::decode_array(text).ok(),
73        ("uint256[]", Json::Array(items)) => {
74            let mut sponge = Keccak256::new();
75            for item in items {
76                sponge = sponge.chain(&encode_json("uint256", item)?);
77            }
78            Some(sponge.finalize())
79        }
80        // A wallet takes a number either way: a JSON number or its decimal string.
81        (_, Json::Number(digits) | Json::String(digits)) => {
82            let bits: usize = kind.strip_prefix("uint")?.parse().ok()?;
83            let value = decimal_word(digits)?;
84            let spare = 256usize.checked_sub(bits)? / 8;
85            value[..spare].iter().all(|byte| *byte == 0).then_some(value)
86        }
87        _ => None,
88    }
89}
90
91#[cfg(test)]
92#[path = "tests/wallet.rs"]
93mod tests;