Skip to main content

chaincodec_evm/
eip712.rs

1//! EIP-712 Typed Structured Data decoder.
2//!
3//! Parses `eth_signTypedData_v4` JSON payloads into strongly-typed Rust structures.
4//!
5//! EIP-712 defines a way to hash and sign typed structured data in a human-readable way.
6//! The JSON format has three top-level keys:
7//! - `types` — struct type definitions (including `EIP712Domain`)
8//! - `primaryType` — the name of the root type being signed
9//! - `domain` — domain separator values
10//! - `message` — the actual data being signed
11//!
12//! # Reference
13//! <https://eips.ethereum.org/EIPS/eip-712>
14
15use chaincodec_core::types::NormalizedValue;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::collections::HashMap;
19
20/// A single field within an EIP-712 type definition.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct Eip712TypeField {
23    /// Field name
24    pub name: String,
25    /// Solidity type string (e.g. "address", "uint256", "MyStruct")
26    #[serde(rename = "type")]
27    pub ty: String,
28}
29
30/// A parsed EIP-712 typed data payload (eth_signTypedData_v4 format).
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct TypedData {
33    /// All type definitions (struct name → field list)
34    pub types: HashMap<String, Vec<Eip712TypeField>>,
35    /// The root type being signed (must exist in `types`)
36    pub primary_type: String,
37    /// Domain separator values
38    pub domain: HashMap<String, TypedValue>,
39    /// The structured data to be signed
40    pub message: HashMap<String, TypedValue>,
41}
42
43/// A value in typed data — may be primitive or nested struct.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45#[serde(untagged)]
46pub enum TypedValue {
47    Null,
48    Bool(bool),
49    Number(serde_json::Number),
50    Str(String),
51    Array(Vec<TypedValue>),
52    Object(HashMap<String, TypedValue>),
53}
54
55impl TypedValue {
56    /// Convert to a canonical `NormalizedValue` using a type hint.
57    pub fn to_normalized(&self, type_hint: &str) -> NormalizedValue {
58        match self {
59            TypedValue::Null => NormalizedValue::Null,
60            TypedValue::Bool(b) => NormalizedValue::Bool(*b),
61            TypedValue::Str(s) => {
62                if type_hint == "address" {
63                    NormalizedValue::Address(s.clone())
64                } else if type_hint.starts_with("bytes") {
65                    // Hex-encoded bytes
66                    let hex = s.strip_prefix("0x").unwrap_or(s);
67                    match hex::decode(hex) {
68                        Ok(b) => NormalizedValue::Bytes(b),
69                        Err(_) => NormalizedValue::Str(s.clone()),
70                    }
71                } else {
72                    NormalizedValue::Str(s.clone())
73                }
74            }
75            TypedValue::Number(n) => {
76                if let Some(u) = n.as_u64() {
77                    NormalizedValue::Uint(u as u128)
78                } else if let Some(i) = n.as_i64() {
79                    NormalizedValue::Int(i as i128)
80                } else {
81                    NormalizedValue::Str(n.to_string())
82                }
83            }
84            TypedValue::Array(elems) => {
85                // For arrays: strip trailing "[]" from type hint
86                let inner_type = type_hint.strip_suffix("[]").unwrap_or(type_hint);
87                NormalizedValue::Array(
88                    elems.iter().map(|e| e.to_normalized(inner_type)).collect(),
89                )
90            }
91            TypedValue::Object(fields) => {
92                // Struct — fields become a tuple
93                let named: Vec<(String, NormalizedValue)> = fields
94                    .iter()
95                    .map(|(k, v)| (k.clone(), v.to_normalized("unknown")))
96                    .collect();
97                NormalizedValue::Tuple(named)
98            }
99        }
100    }
101}
102
103/// Parser for EIP-712 JSON payloads.
104pub struct Eip712Parser;
105
106impl Eip712Parser {
107    /// Parse a JSON string conforming to the EIP-712 `eth_signTypedData_v4` format.
108    ///
109    /// # Errors
110    /// Returns an error if the JSON is malformed or missing required fields.
111    pub fn parse(json: &str) -> Result<TypedData, String> {
112        let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
113
114        let types_raw = v.get("types").ok_or("missing 'types' field")?;
115        let types = parse_types(types_raw)?;
116
117        let primary_type = v
118            .get("primaryType")
119            .and_then(|v| v.as_str())
120            .ok_or("missing 'primaryType'")?
121            .to_string();
122
123        let domain_raw = v.get("domain").ok_or("missing 'domain'")?;
124        let domain = parse_object_values(domain_raw)?;
125
126        let message_raw = v.get("message").ok_or("missing 'message'")?;
127        let message = parse_object_values(message_raw)?;
128
129        Ok(TypedData {
130            types,
131            primary_type,
132            domain,
133            message,
134        })
135    }
136
137    /// Returns the type fields for the primary type.
138    pub fn primary_type_fields<'a>(td: &'a TypedData) -> Option<&'a Vec<Eip712TypeField>> {
139        td.types.get(&td.primary_type)
140    }
141
142    /// Compute the EIP-712 domain separator hash.
143    ///
144    /// Returns the domain separator as a hex string.
145    pub fn domain_separator_hex(td: &TypedData) -> String {
146        // Domain type hash = keccak256(encode_type("EIP712Domain", types))
147        // For simplicity we serialize the domain to JSON and hash it
148        // (production impl should use proper ABI encoding)
149        let domain_json = serde_json::to_string(&td.domain).unwrap_or_default();
150        let hash = tiny_keccak::keccak256(domain_json.as_bytes());
151        format!("0x{}", hex::encode(hash))
152    }
153}
154
155fn parse_types(v: &Value) -> Result<HashMap<String, Vec<Eip712TypeField>>, String> {
156    let obj = v.as_object().ok_or("'types' must be an object")?;
157    let mut types = HashMap::new();
158    for (type_name, fields_val) in obj {
159        let fields_arr = fields_val
160            .as_array()
161            .ok_or_else(|| format!("type '{}' fields must be an array", type_name))?;
162        let mut fields = Vec::new();
163        for field_val in fields_arr {
164            let name = field_val
165                .get("name")
166                .and_then(|v| v.as_str())
167                .ok_or_else(|| format!("field in '{}' missing 'name'", type_name))?
168                .to_string();
169            let ty = field_val
170                .get("type")
171                .and_then(|v| v.as_str())
172                .ok_or_else(|| format!("field '{}' in '{}' missing 'type'", name, type_name))?
173                .to_string();
174            fields.push(Eip712TypeField { name, ty });
175        }
176        types.insert(type_name.clone(), fields);
177    }
178    Ok(types)
179}
180
181fn parse_object_values(v: &Value) -> Result<HashMap<String, TypedValue>, String> {
182    let obj = v.as_object().ok_or("expected JSON object")?;
183    let mut map = HashMap::new();
184    for (k, v) in obj {
185        map.insert(k.clone(), json_to_typed_value(v));
186    }
187    Ok(map)
188}
189
190fn json_to_typed_value(v: &Value) -> TypedValue {
191    match v {
192        Value::Null => TypedValue::Null,
193        Value::Bool(b) => TypedValue::Bool(*b),
194        Value::Number(n) => TypedValue::Number(n.clone()),
195        Value::String(s) => TypedValue::Str(s.clone()),
196        Value::Array(arr) => TypedValue::Array(arr.iter().map(json_to_typed_value).collect()),
197        Value::Object(obj) => {
198            let map: HashMap<String, TypedValue> = obj
199                .iter()
200                .map(|(k, v)| (k.clone(), json_to_typed_value(v)))
201                .collect();
202            TypedValue::Object(map)
203        }
204    }
205}
206
207// Internal helper - tiny_keccak wrapper
208mod tiny_keccak {
209    pub fn keccak256(data: &[u8]) -> [u8; 32] {
210        use ::tiny_keccak::{Hasher, Keccak};
211        let mut k = Keccak::v256();
212        k.update(data);
213        let mut out = [0u8; 32];
214        k.finalize(&mut out);
215        out
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    // Example from EIP-712 spec
224    const EIP712_EXAMPLE: &str = r#"{
225        "types": {
226            "EIP712Domain": [
227                {"name": "name",              "type": "string"},
228                {"name": "version",           "type": "string"},
229                {"name": "chainId",           "type": "uint256"},
230                {"name": "verifyingContract", "type": "address"}
231            ],
232            "Mail": [
233                {"name": "from",     "type": "Person"},
234                {"name": "to",       "type": "Person"},
235                {"name": "contents", "type": "string"}
236            ],
237            "Person": [
238                {"name": "name",   "type": "string"},
239                {"name": "wallet", "type": "address"}
240            ]
241        },
242        "primaryType": "Mail",
243        "domain": {
244            "name": "Ether Mail",
245            "version": "1",
246            "chainId": 1,
247            "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
248        },
249        "message": {
250            "from": {
251                "name": "Cow",
252                "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"
253            },
254            "to": {
255                "name": "Bob",
256                "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"
257            },
258            "contents": "Hello, Bob!"
259        }
260    }"#;
261
262    #[test]
263    fn parse_eip712_example() {
264        let td = Eip712Parser::parse(EIP712_EXAMPLE).unwrap();
265        assert_eq!(td.primary_type, "Mail");
266        assert!(td.types.contains_key("EIP712Domain"));
267        assert!(td.types.contains_key("Mail"));
268        assert!(td.types.contains_key("Person"));
269    }
270
271    #[test]
272    fn primary_type_fields_count() {
273        let td = Eip712Parser::parse(EIP712_EXAMPLE).unwrap();
274        let fields = Eip712Parser::primary_type_fields(&td).unwrap();
275        assert_eq!(fields.len(), 3); // from, to, contents
276    }
277
278    #[test]
279    fn domain_has_chain_id() {
280        let td = Eip712Parser::parse(EIP712_EXAMPLE).unwrap();
281        assert!(td.domain.contains_key("chainId"));
282    }
283
284    #[test]
285    fn message_contents() {
286        let td = Eip712Parser::parse(EIP712_EXAMPLE).unwrap();
287        let contents = td.message.get("contents").unwrap();
288        assert_eq!(*contents, TypedValue::Str("Hello, Bob!".into()));
289    }
290
291    #[test]
292    fn missing_fields_return_error() {
293        let bad_json = r#"{"types": {}}"#;
294        let result = Eip712Parser::parse(bad_json);
295        assert!(result.is_err());
296    }
297}