Skip to main content

df_hyperliquid_core/utils/sign/
eip712.rs

1//! EIP-712 for the flat structs Hyperliquid signs, and HyperEVM contracts that
2//! take the same kind (one level, `uint256[]` the only array): hashing, and the
3//! typed data a browser wallet's `eth_signTypedData_v4` is handed.
4
5use crate::client::network::{L1_CHAIN_ID, Network};
6use crate::utils::address::Address;
7use crate::utils::hex;
8use crate::utils::json::{BadJson, Json};
9use crate::utils::sign::keccak::{Keccak256, keccak256};
10use crate::utils::sign::msgpack::Value;
11
12pub const DOMAIN_TYPE: &str = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct Domain {
16    pub name: &'static str,
17    pub version: &'static str,
18    pub chain_id: u64,
19    pub verifying_contract: Address,
20}
21
22impl Domain {
23    /// What an L1 action's phantom agent is signed under, on both networks.
24    /// The chain id is the literal 1337, whatever chain the wallet is on; the
25    /// network is in the phantom agent's `source`.
26    pub const EXCHANGE: Domain =
27        Domain { name: "Exchange", version: "1", chain_id: L1_CHAIN_ID, verifying_contract: Address::ZERO };
28
29    /// What a user-signed action is signed under: the chain the action names
30    /// as its `signatureChainId`.
31    #[must_use]
32    pub const fn user_signed(network: Network) -> Domain {
33        Domain {
34            name: "HyperliquidSignTransaction",
35            version: "1",
36            chain_id: network.signature_chain_id(),
37            verifying_contract: Address::ZERO,
38        }
39    }
40
41    #[must_use]
42    pub fn separator(&self) -> [u8; 32] {
43        Keccak256::new()
44            .chain(&keccak256(DOMAIN_TYPE.as_bytes()))
45            .chain(&keccak256(self.name.as_bytes()))
46            .chain(&keccak256(self.version.as_bytes()))
47            .chain(&word(self.chain_id))
48            .chain(&address_word(self.verifying_contract))
49            .finalize()
50    }
51
52    /// The `domain` object of wallet typed data: `chainId` a number, as viem sends it.
53    fn to_json(self) -> Json {
54        Json::Object(vec![
55            ("name".to_owned(), Json::String(self.name.to_owned())),
56            ("version".to_owned(), Json::String(self.version.to_owned())),
57            ("chainId".to_owned(), Json::Number(self.chain_id.to_string())),
58            ("verifyingContract".to_owned(), Json::String(self.verifying_contract.to_hex())),
59        ])
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Field<'a> {
65    String(&'a str),
66    Address(Address),
67    Uint64(u64),
68    Bytes32([u8; 32]),
69    Bool(bool),
70    Uint32(u32),
71    Uint128(u128),
72    /// The big-endian word; [`uint256`] makes one from a smaller number.
73    Uint256([u8; 32]),
74    /// Each element's big-endian word, in order.
75    Uint256Array(&'a [[u8; 32]]),
76}
77
78/// The `uint256` word of a number that fits 128 bits.
79#[must_use]
80pub const fn uint256(value: u128) -> [u8; 32] {
81    let bytes = value.to_be_bytes();
82    let mut word = [0u8; 32];
83    let mut index = 0;
84    while index < 16 {
85        word[16 + index] = bytes[index];
86        index += 1;
87    }
88    word
89}
90
91impl Field<'_> {
92    #[must_use]
93    pub const fn solidity_type(&self) -> &'static str {
94        match self {
95            Field::String(_) => "string",
96            Field::Address(_) => "address",
97            Field::Uint64(_) => "uint64",
98            Field::Bytes32(_) => "bytes32",
99            Field::Bool(_) => "bool",
100            Field::Uint32(_) => "uint32",
101            Field::Uint128(_) => "uint128",
102            Field::Uint256(_) => "uint256",
103            Field::Uint256Array(_) => "uint256[]",
104        }
105    }
106
107    /// The field's word in the struct hash. An array's is the keccak of its
108    /// elements' words, as EIP-712 encodes one.
109    #[must_use]
110    pub fn encode(&self) -> [u8; 32] {
111        match self {
112            Field::String(text) => keccak256(text.as_bytes()),
113            Field::Address(address) => address_word(*address),
114            Field::Uint64(number) => word(*number),
115            Field::Bytes32(bytes) | Field::Uint256(bytes) => *bytes,
116            Field::Bool(value) => word(u64::from(*value)),
117            Field::Uint32(number) => word(u64::from(*number)),
118            Field::Uint128(number) => uint256(*number),
119            Field::Uint256Array(words) => {
120                words.iter().fold(Keccak256::new(), |sponge, element| sponge.chain(element)).finalize()
121            }
122        }
123    }
124
125    /// The value as a wallet's `message` carries it: numbers as JSON numbers,
126    /// as viem passes them, bools as JSON bools, the rest as strings. A
127    /// `uint128` or `uint256` is a decimal string, as viem sends a bigint:
128    /// JavaScript reads a JSON number past 2^53 inexactly.
129    #[must_use]
130    pub fn to_json(&self) -> Json {
131        match self {
132            Field::String(text) => Json::String((*text).to_owned()),
133            Field::Address(address) => Json::String(address.to_hex()),
134            Field::Uint64(number) => Json::Number(number.to_string()),
135            Field::Bytes32(bytes) => Json::String(hex::encode_prefixed(bytes)),
136            Field::Bool(value) => Json::Bool(*value),
137            Field::Uint32(number) => Json::Number(number.to_string()),
138            Field::Uint128(number) => Json::String(number.to_string()),
139            Field::Uint256(bytes) => Json::String(word_decimal(bytes)),
140            Field::Uint256Array(words) => {
141                Json::Array(words.iter().map(|element| Json::String(word_decimal(element))).collect())
142            }
143        }
144    }
145
146    /// The value as the posted action carries it: addresses lowercase. A
147    /// number past 64 bits is its decimal string, as no msgpack integer holds it.
148    #[must_use]
149    pub fn to_wire(&self) -> Value {
150        match self {
151            Field::String(text) => Value::from((*text).to_owned()),
152            Field::Address(address) => Value::from(address.to_hex()),
153            Field::Uint64(number) => Value::Uint(*number),
154            Field::Bytes32(bytes) => Value::from(hex::encode_prefixed(bytes)),
155            Field::Bool(value) => Value::Bool(*value),
156            Field::Uint32(number) => Value::Uint(u64::from(*number)),
157            Field::Uint128(number) => Value::from(number.to_string()),
158            Field::Uint256(bytes) => Value::from(word_decimal(bytes)),
159            Field::Uint256Array(words) => {
160                Value::Array(words.iter().map(|element| Value::from(word_decimal(element))).collect())
161            }
162        }
163    }
164}
165
166/// A big-endian word as its decimal digits.
167#[must_use]
168pub fn word_decimal(bytes: &[u8; 32]) -> String {
169    // Nineteen digits at a time: 10^19 is the largest power of ten under 2^64.
170    const GROUP: u128 = 10_000_000_000_000_000_000;
171    let mut limbs = [0u64; 4];
172    for (limb, chunk) in limbs.iter_mut().zip(bytes.chunks_exact(8)) {
173        let mut eight = [0u8; 8];
174        eight.copy_from_slice(chunk);
175        *limb = u64::from_be_bytes(eight);
176    }
177    let mut groups = Vec::new();
178    while limbs.iter().any(|limb| *limb != 0) {
179        let mut remainder = 0u128;
180        for limb in &mut limbs {
181            let value = (remainder << 64) | u128::from(*limb);
182            // The remainder is under 10^19, so the quotient is under 2^64.
183            *limb = (value / GROUP) as u64;
184            remainder = value % GROUP;
185        }
186        groups.push(remainder as u64);
187    }
188    let Some((first, rest)) = groups.split_last() else {
189        return "0".to_owned();
190    };
191    let mut text = first.to_string();
192    for group in rest.iter().rev() {
193        text.push_str(&format!("{group:019}"));
194    }
195    text
196}
197
198/// Decimal digits as a big-endian word; `None` for anything else or past 2^256.
199#[must_use]
200pub fn decimal_word(digits: &str) -> Option<[u8; 32]> {
201    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
202        return None;
203    }
204    let mut limbs = [0u64; 4];
205    for byte in digits.bytes() {
206        let mut carry = u128::from(byte - b'0');
207        for limb in limbs.iter_mut().rev() {
208            let value = u128::from(*limb) * 10 + carry;
209            // The low 64 bits stay in the limb; the rest carries up.
210            *limb = value as u64;
211            carry = value >> 64;
212        }
213        if carry != 0 {
214            return None;
215        }
216    }
217    let mut word = [0u8; 32];
218    for (chunk, limb) in word.chunks_exact_mut(8).zip(limbs) {
219        chunk.copy_from_slice(&limb.to_be_bytes());
220    }
221    Some(word)
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct TypedStruct<'a> {
226    primary_type: &'a str,
227    fields: Vec<(&'a str, Field<'a>)>,
228}
229
230impl<'a> TypedStruct<'a> {
231    #[must_use]
232    pub const fn new(primary_type: &'a str) -> Self {
233        TypedStruct { primary_type, fields: Vec::new() }
234    }
235
236    /// Appends a field; call order is the order in the type string and hash.
237    #[must_use]
238    pub fn field(mut self, name: &'a str, value: Field<'a>) -> Self {
239        self.fields.push((name, value));
240        self
241    }
242
243    #[must_use]
244    pub const fn primary_type(&self) -> &'a str {
245        self.primary_type
246    }
247
248    #[must_use]
249    pub fn fields(&self) -> &[(&'a str, Field<'a>)] {
250        &self.fields
251    }
252
253    #[must_use]
254    pub fn type_signature(&self) -> String {
255        let mut text = String::with_capacity(self.primary_type.len() + 32);
256        text.push_str(self.primary_type);
257        text.push('(');
258        for (index, (name, value)) in self.fields.iter().enumerate() {
259            if index > 0 {
260                text.push(',');
261            }
262            text.push_str(value.solidity_type());
263            text.push(' ');
264            text.push_str(name);
265        }
266        text.push(')');
267        text
268    }
269
270    #[must_use]
271    pub fn type_hash(&self) -> [u8; 32] {
272        keccak256(self.type_signature().as_bytes())
273    }
274
275    #[must_use]
276    pub fn hash(&self) -> [u8; 32] {
277        let mut sponge = Keccak256::new().chain(&self.type_hash());
278        for (_, value) in &self.fields {
279            sponge = sponge.chain(&value.encode());
280        }
281        sponge.finalize()
282    }
283
284    /// What the key signs under `domain`.
285    #[must_use]
286    pub fn digest(&self, domain: &Domain) -> [u8; 32] {
287        digest(domain, self)
288    }
289
290    /// The typed data a browser wallet's `eth_signTypedData_v4` takes, as one
291    /// line: `{types: {EIP712Domain, <primary>}, primaryType, domain, message}`.
292    /// A wallet that signs it signs [`TypedStruct::digest`].
293    #[must_use]
294    pub fn wallet_json(&self, domain: &Domain) -> String {
295        let list = |pairs: Vec<(&str, &str)>| {
296            Json::Array(
297                pairs
298                    .into_iter()
299                    .map(|(name, kind)| {
300                        Json::Object(vec![
301                            ("name".to_owned(), Json::String(name.to_owned())),
302                            ("type".to_owned(), Json::String(kind.to_owned())),
303                        ])
304                    })
305                    .collect(),
306            )
307        };
308        let domain_fields = list(vec![
309            ("name", "string"),
310            ("version", "string"),
311            ("chainId", "uint256"),
312            ("verifyingContract", "address"),
313        ]);
314        let struct_fields = list(self.fields.iter().map(|(name, value)| (*name, value.solidity_type())).collect());
315        let message =
316            Json::Object(self.fields.iter().map(|(name, value)| ((*name).to_owned(), value.to_json())).collect());
317        Json::Object(vec![
318            (
319                "types".to_owned(),
320                Json::Object(vec![
321                    ("EIP712Domain".to_owned(), domain_fields),
322                    (self.primary_type.to_owned(), struct_fields),
323                ]),
324            ),
325            ("primaryType".to_owned(), Json::String(self.primary_type.to_owned())),
326            ("domain".to_owned(), domain.to_json()),
327            ("message".to_owned(), message),
328        ])
329        .compact()
330    }
331}
332
333/// `keccak256(0x1901 || domain separator || struct hash)`.
334#[must_use]
335pub fn digest(domain: &Domain, data: &TypedStruct<'_>) -> [u8; 32] {
336    Keccak256::new().chain(&[0x19, 0x01]).chain(&domain.separator()).chain(&data.hash()).finalize()
337}
338
339/// Why wallet typed data could not be hashed.
340#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
341pub enum BadTypedData {
342    #[error("not JSON: {0}")]
343    Json(#[from] BadJson),
344    #[error("{0}")]
345    Shape(String),
346}
347
348/// What a wallet hashes for `eth_signTypedData_v4` JSON, read from the JSON
349/// alone: the type strings from its `types` lists, the values from `domain`
350/// and `message`. A cross-check of [`TypedStruct::wallet_json`] that shares
351/// nothing with it but keccak. Flat structs and the types used here only.
352pub fn digest_of_wallet_json(text: &str) -> Result<[u8; 32], BadTypedData> {
353    let shape = |what: &str| BadTypedData::Shape(what.to_owned());
354    let json = Json::parse(text)?;
355    let types = json.get("types").ok_or_else(|| shape("no `types`"))?;
356    let primary = json.get("primaryType").and_then(Json::as_str).ok_or_else(|| shape("no `primaryType`"))?;
357    let list = |name: &str| {
358        types.get(name).and_then(Json::as_array).ok_or_else(|| BadTypedData::Shape(format!("no type `{name}`")))
359    };
360    let domain = json.get("domain").ok_or_else(|| shape("no `domain`"))?;
361    let message = json.get("message").ok_or_else(|| shape("no `message`"))?;
362    let separator = struct_hash("EIP712Domain", list("EIP712Domain")?, domain)?;
363    let data = struct_hash(primary, list(primary)?, message)?;
364    Ok(Keccak256::new().chain(&[0x19, 0x01]).chain(&separator).chain(&data).finalize())
365}
366
367fn struct_hash(name: &str, fields: &[Json], values: &Json) -> Result<[u8; 32], BadTypedData> {
368    let mut signature = format!("{name}(");
369    let mut words = Vec::with_capacity(fields.len());
370    for (index, field) in fields.iter().enumerate() {
371        let (Some(field_name), Some(kind)) =
372            (field.get("name").and_then(Json::as_str), field.get("type").and_then(Json::as_str))
373        else {
374            return Err(BadTypedData::Shape(format!("a field of `{name}` without a name and a type")));
375        };
376        if index > 0 {
377            signature.push(',');
378        }
379        signature.push_str(kind);
380        signature.push(' ');
381        signature.push_str(field_name);
382        let value = values
383            .get(field_name)
384            .ok_or_else(|| BadTypedData::Shape(format!("`{name}` has no value for `{field_name}`")))?;
385        let word = encode_json(kind, value)
386            .ok_or_else(|| BadTypedData::Shape(format!("`{field_name}` is not a `{kind}`: {}", value.compact())))?;
387        words.push(word);
388    }
389    signature.push(')');
390    let mut sponge = Keccak256::new().chain(&keccak256(signature.as_bytes()));
391    for word in &words {
392        sponge = sponge.chain(word);
393    }
394    Ok(sponge.finalize())
395}
396
397fn encode_json(kind: &str, value: &Json) -> Option<[u8; 32]> {
398    match (kind, value) {
399        ("string", Json::String(text)) => Some(keccak256(text.as_bytes())),
400        ("address", Json::String(text)) => text.parse().ok().map(address_word),
401        ("bool", Json::Bool(flag)) => Some(word(u64::from(*flag))),
402        ("bytes32", Json::String(text)) => hex::decode_array(text).ok(),
403        ("uint256[]", Json::Array(items)) => {
404            let mut sponge = Keccak256::new();
405            for item in items {
406                sponge = sponge.chain(&encode_json("uint256", item)?);
407            }
408            Some(sponge.finalize())
409        }
410        // A wallet takes a number either way: a JSON number or its decimal string.
411        (_, Json::Number(digits) | Json::String(digits)) => {
412            let bits: usize = kind.strip_prefix("uint")?.parse().ok()?;
413            let value = decimal_word(digits)?;
414            let spare = 256usize.checked_sub(bits)? / 8;
415            value[..spare].iter().all(|byte| *byte == 0).then_some(value)
416        }
417        _ => None,
418    }
419}
420
421fn word(value: u64) -> [u8; 32] {
422    let mut bytes = [0u8; 32];
423    bytes[24..].copy_from_slice(&value.to_be_bytes());
424    bytes
425}
426
427fn address_word(address: Address) -> [u8; 32] {
428    let mut bytes = [0u8; 32];
429    bytes[12..].copy_from_slice(address.bytes());
430    bytes
431}
432
433#[cfg(test)]
434#[path = "tests/eip712.rs"]
435mod tests;