From 3d76ce816a33973079d605bbdc7ac63f5a5c924c Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Wed, 27 Jul 2022 22:56:02 +0200 Subject: [PATCH] feat: add support for EIP-712 typed data (#1510) * feat: add support for typeddata * change eip712 trait * feat: impl eip712 trait * chore(clippy): make clippy happy * better numeric parsing * fix: fix a bunch of encoding bugs * Update ethers-core/Cargo.toml Co-authored-by: Georgios Konstantopoulos --- ethers-core/src/types/mod.rs | 2 + ethers-core/src/types/serde_helpers.rs | 147 ++++ ethers-core/src/types/transaction/eip712.rs | 839 ++++++++++++++++++-- 3 files changed, 911 insertions(+), 77 deletions(-) create mode 100644 ethers-core/src/types/serde_helpers.rs diff --git a/ethers-core/src/types/mod.rs b/ethers-core/src/types/mod.rs index c5701727..a5703e95 100644 --- a/ethers-core/src/types/mod.rs +++ b/ethers-core/src/types/mod.rs @@ -66,3 +66,5 @@ pub use fee::*; mod other; pub use other::OtherFields; + +pub mod serde_helpers; diff --git a/ethers-core/src/types/serde_helpers.rs b/ethers-core/src/types/serde_helpers.rs new file mode 100644 index 00000000..6939c01f --- /dev/null +++ b/ethers-core/src/types/serde_helpers.rs @@ -0,0 +1,147 @@ +//! Some convenient serde helpers + +use crate::types::{BlockNumber, U256}; +use ethabi::ethereum_types::FromDecStrErr; +use serde::{Deserialize, Deserializer}; + +/// Helper type to parse both `u64` and `U256` +#[derive(Deserialize)] +#[serde(untagged)] +pub enum Numeric { + U256(U256), + Num(u64), +} + +impl From for U256 { + fn from(n: Numeric) -> U256 { + match n { + Numeric::U256(n) => n, + Numeric::Num(n) => U256::from(n), + } + } +} + +/// Helper type to parse both `u64` and `U256` +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum StringifiedNumeric { + String(String), + U256(U256), + Num(u64), +} + +impl TryFrom for U256 { + type Error = FromDecStrErr; + + fn try_from(value: StringifiedNumeric) -> Result { + match value { + StringifiedNumeric::U256(n) => Ok(n), + StringifiedNumeric::Num(n) => Ok(U256::from(n)), + StringifiedNumeric::String(s) => { + if let Ok(val) = s.parse::() { + Ok(val.into()) + } else { + U256::from_dec_str(&s) + } + } + } + } +} + +/// Helper type to deserialize sequence of numbers +#[derive(Deserialize)] +#[serde(untagged)] +pub enum NumericSeq { + Seq([Numeric; 1]), + U256(U256), + Num(u64), +} + +/// Deserializes a number from hex or int +pub fn deserialize_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Numeric::deserialize(deserializer).map(Into::into) +} + +/// Deserializes a number from hex or int, but optionally +pub fn deserialize_number_opt<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let num = match Option::::deserialize(deserializer)? { + Some(Numeric::U256(n)) => Some(n), + Some(Numeric::Num(n)) => Some(U256::from(n)), + _ => None, + }; + + Ok(num) +} + +/// Deserializes single integer params: `1, [1], ["0x01"]` +pub fn deserialize_number_seq<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let num = match NumericSeq::deserialize(deserializer)? { + NumericSeq::Seq(seq) => seq.into_iter().next().unwrap().into(), + NumericSeq::U256(n) => n, + NumericSeq::Num(n) => U256::from(n), + }; + + Ok(num) +} + +/// Various block number representations, See [`lenient_block_number()`] +#[derive(Deserialize)] +#[serde(untagged)] +pub enum LenientBlockNumber { + BlockNumber(BlockNumber), + Num(u64), +} + +impl From for BlockNumber { + fn from(b: LenientBlockNumber) -> Self { + match b { + LenientBlockNumber::BlockNumber(b) => b, + LenientBlockNumber::Num(b) => b.into(), + } + } +} + +/// Following the spec the block parameter is either: +/// +/// > HEX String - an integer block number +/// > String "earliest" for the earliest/genesis block +/// > String "latest" - for the latest mined block +/// > String "pending" - for the pending state/transactions +/// +/// and with EIP-1898: +/// > blockNumber: QUANTITY - a block number +/// > blockHash: DATA - a block hash +/// +/// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1898.md +/// +/// EIP-1898 does not all calls that use `BlockNumber` like `eth_getBlockByNumber` and doesn't list +/// raw integers as supported. +/// +/// However, there are dev node implementations that support integers, such as ganache: +/// +/// N.B.: geth does not support ints in `eth_getBlockByNumber` +pub fn lenient_block_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + LenientBlockNumber::deserialize(deserializer).map(Into::into) +} + +/// Same as `lenient_block_number` but requires to be `[num; 1]` +pub fn lenient_block_number_seq<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let num = + <[LenientBlockNumber; 1]>::deserialize(deserializer)?.into_iter().next().unwrap().into(); + Ok(num) +} diff --git a/ethers-core/src/types/transaction/eip712.rs b/ethers-core/src/types/transaction/eip712.rs index 5bb7c978..fb9100a3 100644 --- a/ethers-core/src/types/transaction/eip712.rs +++ b/ethers-core/src/types/transaction/eip712.rs @@ -1,17 +1,22 @@ +use crate::{ + abi, + abi::{HumanReadableParser, ParamType, Token}, + types::{serde_helpers::StringifiedNumeric, Address, Bytes, U256}, + utils::keccak256, +}; use convert_case::{Case, Casing}; use core::convert::TryFrom; +use ethabi::encode; use proc_macro2::TokenStream; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::{BTreeMap, HashSet}; use syn::{ parse::Error, spanned::Spanned as _, AttrStyle, Data, DeriveInput, Expr, Fields, GenericArgument, Lit, NestedMeta, PathArguments, Type, }; -use crate::{ - abi, - abi::{ParamType, Token}, - types::{Address, U256}, - utils::keccak256, -}; +/// Custom types for `TypedData` +pub type Types = BTreeMap>; /// Pre-computed value of the following statement: /// @@ -45,7 +50,7 @@ pub enum Eip712Error { #[error("Nested Eip712 struct not implemented. Failed to parse.")] NestedEip712StructNotImplemented, #[error("Error from Eip712 struct: {0:?}")] - Inner(String), + Message(String), } /// The Eip712 trait provides helper methods for computing @@ -102,24 +107,37 @@ pub trait Eip712 { /// Eip712 Domain attributes used in determining the domain separator; /// Unused fields are left out of the struct type. -#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +/// +/// Protocol designers only need to include the fields that make sense for their signing domain. +/// Unused fields are left out of the struct type. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct EIP712Domain { /// The user readable name of signing domain, i.e. the name of the DApp or the protocol. - pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, /// The current major version of the signing domain. Signatures from different versions are not /// compatible. - pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, /// The EIP-155 chain id. The user-agent should refuse signing if it does not match the /// currently active chain. - pub chain_id: U256, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "crate::types::serde_helpers::deserialize_number_opt" + )] + pub chain_id: Option, /// The address of the contract that will verify the signature. - pub verifying_contract: Address, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verifying_contract: Option
, /// A disambiguating salt for the protocol. This can be used as a domain separator of last /// resort. + #[serde(default, skip_serializing_if = "Option::is_none")] pub salt: Option<[u8; 32]>, } @@ -127,26 +145,58 @@ impl EIP712Domain { // Compute the domain separator; // See: https://github.com/gakonst/ethers-rs/blob/master/examples/permit_hash.rs#L41 pub fn separator(&self) -> [u8; 32] { - let domain_type_hash = if self.salt.is_some() { - EIP712_DOMAIN_TYPE_HASH_WITH_SALT - } else { - EIP712_DOMAIN_TYPE_HASH - }; + // full name is `EIP712Domain(string name,string version,uint256 chainId,address + // verifyingContract,bytes32 salt)` + let mut ty = "EIP712Domain(".to_string(); - let mut tokens = vec![ - Token::Uint(U256::from(domain_type_hash)), - Token::Uint(U256::from(keccak256(&self.name))), - Token::Uint(U256::from(keccak256(&self.version))), - Token::Uint(self.chain_id), - Token::Address(self.verifying_contract), - ]; + let mut tokens = Vec::new(); + let mut needs_comma = false; + if let Some(ref name) = self.name { + ty += "string name"; + tokens.push(Token::Uint(U256::from(keccak256(name)))); + needs_comma = true; + } - // Add the salt to the struct to be hashed if it exists; - if let Some(salt) = &self.salt { + if let Some(ref version) = self.version { + if needs_comma { + ty.push(','); + } + ty += "string version"; + tokens.push(Token::Uint(U256::from(keccak256(version)))); + needs_comma = true; + } + + if let Some(chain_id) = self.chain_id { + if needs_comma { + ty.push(','); + } + ty += "uint256 chainId"; + tokens.push(Token::Uint(chain_id)); + needs_comma = true; + } + + if let Some(verifying_contract) = self.verifying_contract { + if needs_comma { + ty.push(','); + } + ty += "address verifyingContract"; + tokens.push(Token::Address(verifying_contract)); + needs_comma = true; + } + + if let Some(salt) = self.salt { + if needs_comma { + ty.push(','); + } + ty += "bytes32 salt"; tokens.push(Token::Uint(U256::from(salt))); } - keccak256(abi::encode(&tokens)) + ty.push(')'); + + tokens.insert(0, Token::Uint(U256::from(keccak256(ty)))); + + keccak256(encode(&tokens)) } } @@ -161,7 +211,7 @@ where impl EIP712WithDomain { pub fn new(inner: T) -> Result { - let domain = inner.domain().map_err(|e| Eip712Error::Inner(e.to_string()))?; + let domain = inner.domain().map_err(|e| Eip712Error::Message(e.to_string()))?; Ok(Self { domain, inner }) } @@ -180,13 +230,13 @@ impl Eip712 for EIP712WithDomain { } fn type_hash() -> Result<[u8; 32], Self::Error> { - let type_hash = T::type_hash().map_err(|e| Self::Error::Inner(e.to_string()))?; + let type_hash = T::type_hash().map_err(|e| Self::Error::Message(e.to_string()))?; Ok(type_hash) } fn struct_hash(&self) -> Result<[u8; 32], Self::Error> { let struct_hash = - self.inner.clone().struct_hash().map_err(|e| Self::Error::Inner(e.to_string()))?; + self.inner.clone().struct_hash().map_err(|e| Self::Error::Message(e.to_string()))?; Ok(struct_hash) } } @@ -196,10 +246,6 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { type Error = TokenStream; fn try_from(input: &syn::DeriveInput) -> Result { let mut domain = EIP712Domain::default(); - let mut domain_name = None; - let mut domain_version = None; - let mut chain_id = None; - let mut verifying_contract = None; let mut found_eip712_attribute = false; @@ -224,7 +270,7 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { match ident.to_string().as_ref() { "name" => match meta.lit { syn::Lit::Str(ref lit_str) => { - if domain_name.is_some() { + if domain.name.is_some() { return Err(Error::new( meta.path.span(), "domain name already specified", @@ -232,7 +278,7 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { .to_compile_error()) } - domain_name = Some(lit_str.value()); + domain.name = Some(lit_str.value()); } _ => { return Err(Error::new( @@ -244,7 +290,7 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { }, "version" => match meta.lit { syn::Lit::Str(ref lit_str) => { - if domain_version.is_some() { + if domain.version.is_some() { return Err(Error::new( meta.path.span(), "domain version already specified", @@ -252,7 +298,7 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { .to_compile_error()) } - domain_version = Some(lit_str.value()); + domain.version = Some(lit_str.value()); } _ => { return Err(Error::new( @@ -264,7 +310,7 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { }, "chain_id" => match meta.lit { syn::Lit::Int(ref lit_int) => { - if chain_id.is_some() { + if domain.chain_id.is_some() { return Err(Error::new( meta.path.span(), "domain chain_id already specified", @@ -272,7 +318,7 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { .to_compile_error()) } - chain_id = Some(U256::from( + domain.chain_id = Some(U256::from( lit_int.base10_parse::().map_err( |_| { Error::new( @@ -294,7 +340,7 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { }, "verifying_contract" => match meta.lit { syn::Lit::Str(ref lit_str) => { - if verifying_contract.is_some() { + if domain.verifying_contract.is_some() { return Err(Error::new( meta.path.span(), "domain verifying_contract already specified", @@ -302,7 +348,7 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { .to_compile_error()); } - verifying_contract = Some(lit_str.value().parse().map_err(|_| { + domain.verifying_contract = Some(lit_str.value().parse().map_err(|_| { Error::new( meta.path.span(), "failed to parse verifying contract into Address", @@ -368,39 +414,6 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { } } } - - domain.name = domain_name.ok_or_else(|| { - Error::new( - meta.path.span(), - "missing required domain attribute: 'name'".to_string(), - ) - .to_compile_error() - })?; - - domain.version = domain_version.ok_or_else(|| { - Error::new( - meta.path.span(), - "missing required domain attribute: 'version'".to_string(), - ) - .to_compile_error() - })?; - - domain.chain_id = chain_id.ok_or_else(|| { - Error::new( - meta.path.span(), - "missing required domain attribute: 'chain_id'".to_string(), - ) - .to_compile_error() - })?; - - domain.verifying_contract = verifying_contract.ok_or_else(|| { - Error::new( - meta.path.span(), - "missing required domain attribute: 'verifying_contract'" - .to_string(), - ) - .to_compile_error() - })?; } break 'attribute_search @@ -420,6 +433,341 @@ impl TryFrom<&syn::DeriveInput> for EIP712Domain { } } +/// Represents the [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed data object. +/// +/// Typed data is a JSON object containing type information, domain separator parameters and the +/// message object which has the following schema +/// +/// ```js +/// { +// type: 'object', +// properties: { +// types: { +// type: 'object', +// properties: { +// EIP712Domain: {type: 'array'}, +// }, +// additionalProperties: { +// type: 'array', +// items: { +// type: 'object', +// properties: { +// name: {type: 'string'}, +// type: {type: 'string'} +// }, +// required: ['name', 'type'] +// } +// }, +// required: ['EIP712Domain'] +// }, +// primaryType: {type: 'string'}, +// domain: {type: 'object'}, +// message: {type: 'object'} +// }, +// required: ['types', 'primaryType', 'domain', 'message'] +// } +/// ``` +/// +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TypedData { + /// Signing domain metadata. The signing domain is the intended context for the signature (e.g. + /// the dapp, protocol, etc. that it's intended for). This data is used to construct the domain + /// seperator of the message. + pub domain: EIP712Domain, + /// The custom types used by this message. + pub types: Types, + #[serde(rename = "primaryType")] + /// The type of the message. + pub primary_type: String, + /// The message to be signed. + pub message: BTreeMap, +} + +/// According to the MetaMask implementation, +/// the message parameter may be JSON stringified in versions later than V1 +/// See https://github.com/MetaMask/metamask-extension/blob/0dfdd44ae7728ed02cbf32c564c75b74f37acf77/app/scripts/metamask-controller.js#L1736 +/// In fact, ethers.js JSON stringifies the message at the time of writing. +impl<'de> Deserialize<'de> for TypedData { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct TypedDataHelper { + domain: EIP712Domain, + types: Types, + #[serde(rename = "primaryType")] + primary_type: String, + message: BTreeMap, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum Type { + Val(TypedDataHelper), + String(String), + } + + match Type::deserialize(deserializer)? { + Type::Val(v) => { + let TypedDataHelper { domain, types, primary_type, message } = v; + Ok(TypedData { domain, types, primary_type, message }) + } + Type::String(s) => { + let TypedDataHelper { domain, types, primary_type, message } = + serde_json::from_str(&s).map_err(serde::de::Error::custom)?; + Ok(TypedData { domain, types, primary_type, message }) + } + } + } +} + +// === impl TypedData === + +impl Eip712 for TypedData { + type Error = Eip712Error; + + fn domain(&self) -> Result { + Ok(self.domain.clone()) + } + + fn type_hash() -> Result<[u8; 32], Self::Error> { + Err(Eip712Error::Message("dynamic type".to_string())) + } + + fn struct_hash(&self) -> Result<[u8; 32], Self::Error> { + let tokens = encode_data( + &self.primary_type, + &serde_json::Value::Object(serde_json::Map::from_iter(self.message.clone())), + &self.types, + )?; + Ok(keccak256(encode(&tokens))) + } + + /// Hash a typed message according to EIP-712. The returned message starts with the EIP-712 + /// prefix, which is "1901", followed by the hash of the domain separator, then the data (if + /// any). The result is hashed again and returned. + fn encode_eip712(&self) -> Result<[u8; 32], Self::Error> { + let domain_separator = self.domain.separator(); + let mut digest_input = [&[0x19, 0x01], &domain_separator[..]].concat().to_vec(); + + if self.primary_type != "EIP712Domain" { + // compatibility with + digest_input.extend(&self.struct_hash()?[..]) + } + Ok(keccak256(digest_input)) + } +} + +/// Represents the name and type pair +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Eip712DomainType { + pub name: String, + #[serde(rename = "type")] + pub r#type: String, +} + +/// Encodes an object by encoding and concatenating each of its members. +/// +/// The encoding of a struct instance is `enc(value₁) ‖ enc(value₂) ‖ … ‖ enc(valueₙ)`, i.e. the +/// concatenation of the encoded member values in the order that they appear in the type. Each +/// encoded member value is exactly 32-byte long. +/// +/// - `primaryType`: The root type. +/// - `data`: The object to encode. +/// - `types`: Type definitions for all types included in the message. +/// +/// Returns an encoded representation of an object +pub fn encode_data( + primary_type: &str, + data: &serde_json::Value, + types: &Types, +) -> Result, Eip712Error> { + let hash = hash_type(primary_type, types)?; + let mut tokens = vec![Token::Uint(U256::from(hash))]; + + if let Some(fields) = types.get(primary_type) { + for field in fields { + // handle recursive types + if let Some(value) = data.get(&field.name) { + let field = encode_field(types, &field.name, &field.r#type, value)?; + tokens.push(field); + } else if types.contains_key(&field.r#type) { + tokens.push(Token::Uint(U256::zero())); + } else { + return Err(Eip712Error::Message(format!("No data found for: `{}`", field.name))) + } + } + } + + Ok(tokens) +} + +/// Hashes an object +/// +/// - `primary_type`: The root type to encode. +/// - `data`: The object to hash. +/// - `types`: All type definitions. +/// +/// Returns the hash of the `primary_type` object +pub fn hash_struct( + primary_type: &str, + data: &serde_json::Value, + types: &Types, +) -> Result<[u8; 32], Eip712Error> { + let tokens = encode_data(primary_type, data, types)?; + let encoded = encode(&tokens); + Ok(keccak256(encoded)) +} + +/// Returns the hashed encoded type of `primary_type` +pub fn hash_type(primary_type: &str, types: &Types) -> Result<[u8; 32], Eip712Error> { + encode_type(primary_type, types).map(keccak256) +} + +/// Encodes the type of an object by encoding a comma delimited list of its members. +/// +/// - `primary_type`: The root type to encode. +/// - `types`: All type definitions. +/// +/// Returns the encoded representation of the field. +pub fn encode_type(primary_type: &str, types: &Types) -> Result { + let mut names = HashSet::new(); + find_type_dependencies(primary_type, types, &mut names); + // need to ensure primary_type is first in the list + names.remove(primary_type); + let mut deps: Vec<_> = names.into_iter().collect(); + deps.sort_unstable(); + deps.insert(0, primary_type); + + let mut res = String::new(); + + for dep in deps.into_iter() { + let fields = types.get(dep).ok_or_else(|| { + Eip712Error::Message(format!("No type definition found for: `{dep}`")) + })?; + + res += dep; + res.push('('); + res += &fields + .iter() + .map(|ty| format!("{} {}", ty.r#type, ty.name)) + .collect::>() + .join(","); + + res.push(')'); + } + Ok(res) +} + +/// Returns all the custom types used in the `primary_type` +fn find_type_dependencies<'a>( + primary_type: &'a str, + types: &'a Types, + found: &mut HashSet<&'a str>, +) { + if found.contains(primary_type) { + return + } + if let Some(fields) = types.get(primary_type) { + found.insert(primary_type); + for field in fields { + // need to strip the array tail + let ty = field.r#type.split('[').next().unwrap(); + find_type_dependencies(ty, types, found) + } + } +} + +/// Encode a single field. +/// +/// - `types`: All type definitions. +/// - `field`: The name and type of the field being encoded. +/// - `value`: The value to encode. +/// +/// Returns the encoded representation of the field. +pub fn encode_field( + types: &Types, + _field_name: &str, + field_type: &str, + value: &serde_json::Value, +) -> Result { + let token = { + // check if field is custom data type + if types.contains_key(field_type) { + let tokens = encode_data(field_type, value, types)?; + let encoded = encode(&tokens); + encode_eip712_type(Token::Bytes(encoded.to_vec())) + } else { + match field_type { + s if s.contains('[') => { + let (stripped_type, _) = s.rsplit_once('[').unwrap(); + // ensure value is an array + let values = value.as_array().ok_or_else(|| { + Eip712Error::Message(format!( + "Expected array for type `{s}`, but got `{value}`", + )) + })?; + let tokens = values + .iter() + .map(|value| encode_field(types, _field_name, stripped_type, value)) + .collect::, _>>()?; + + let encoded = encode(&tokens); + encode_eip712_type(Token::Bytes(encoded)) + } + s => { + // parse as param type + let param = HumanReadableParser::parse_type(s).map_err(|err| { + Eip712Error::Message(format!("Failed to parse type {s}: {err}",)) + })?; + + match param { + ParamType::Address => { + Token::Address(serde_json::from_value(value.clone())?) + } + ParamType::Bytes => { + let data: Bytes = serde_json::from_value(value.clone())?; + encode_eip712_type(Token::Bytes(data.to_vec())) + } + ParamType::Int(_) => Token::Uint(serde_json::from_value(value.clone())?), + ParamType::Uint(_) => { + // uints are commonly stringified due to how ethers-js encodes + let val: StringifiedNumeric = serde_json::from_value(value.clone())?; + let val = val.try_into().map_err(|err| { + Eip712Error::Message(format!("Failed to parse uint {}", err)) + })?; + + Token::Uint(val) + } + ParamType::Bool => { + encode_eip712_type(Token::Bool(serde_json::from_value(value.clone())?)) + } + ParamType::String => { + let s: String = serde_json::from_value(value.clone())?; + encode_eip712_type(Token::String(s)) + } + ParamType::FixedArray(_, _) | ParamType::Array(_) => { + unreachable!("is handled in separate arm") + } + ParamType::FixedBytes(_) => { + let data: Bytes = serde_json::from_value(value.clone())?; + encode_eip712_type(Token::FixedBytes(data.to_vec())) + } + ParamType::Tuple(_) => { + return Err(Eip712Error::Message(format!("Unexpected tuple type {s}",))) + } + } + } + } + } + }; + + Ok(token) +} + /// Parse the eth abi parameter type based on the syntax type; /// this method is copied from /// with additional modifications for finding byte arrays @@ -562,8 +910,6 @@ pub fn make_type_hash(primary_type: String, fields: &[(String, ParamType)]) -> [ } /// Parse token into Eip712 compliant ABI encoding -/// NOTE: Token::Tuple() is currently not supported for solidity structs; -/// this is needed for nested Eip712 types, but is not implemented. pub fn encode_eip712_type(token: Token) -> Token { match token { Token::Bytes(t) => Token::Uint(U256::from(keccak256(t))), @@ -583,9 +929,348 @@ pub fn encode_eip712_type(token: Token) -> Token { Token::FixedArray(tokens) => Token::Uint(U256::from(keccak256(abi::encode( &tokens.into_iter().map(encode_eip712_type).collect::>(), )))), + Token::Tuple(tuple) => { + let tokens = tuple.into_iter().map(encode_eip712_type).collect::>(); + let encoded = encode(&tokens); + Token::Uint(U256::from(keccak256(encoded))) + } _ => { // Return the ABI encoded token; token } } } + +// Adapted tests from +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_full_domain() { + let json = serde_json::json!({ + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + }, + { + "name": "salt", + "type": "bytes32" + } + ] + }, + "primaryType": "EIP712Domain", + "domain": { + "name": "example.metamask.io", + "version": "1", + "chainId": 1, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": {} + }); + + let typed_data: TypedData = serde_json::from_value(json).unwrap(); + + let hash = typed_data.encode_eip712().unwrap(); + assert_eq!( + "122d1c8ef94b76dad44dcb03fa772361e20855c63311a15d5afe02d1b38f6077", + hex::encode(&hash[..]) + ); + } + + #[test] + fn test_minimal_message() { + let json = serde_json::json!( {"types":{"EIP712Domain":[]},"primaryType":"EIP712Domain","domain":{},"message":{}}); + + let typed_data: TypedData = serde_json::from_value(json).unwrap(); + + let hash = typed_data.encode_eip712().unwrap(); + assert_eq!( + "8d4a3f4082945b7879e2b55f181c31a77c8c0a464b70669458abbaaf99de4c38", + hex::encode(&hash[..]) + ); + } + + #[test] + fn test_encode_custom_array_type() { + let json = serde_json::json!({"domain":{},"types":{"EIP712Domain":[],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address[]"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}]},"primaryType":"Mail","message":{"from":{"name":"Cow","wallet":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"]},"to":[{"name":"Bob","wallet":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"]}],"contents":"Hello, Bob!"}}); + + let typed_data: TypedData = serde_json::from_value(json).unwrap(); + + let hash = typed_data.encode_eip712().unwrap(); + assert_eq!( + "80a3aeb51161cfc47884ddf8eac0d2343d6ae640efe78b6a69be65e3045c1321", + hex::encode(&hash[..]) + ); + } + + #[test] + fn test_hash_typed_message_with_data() { + let json = serde_json::json!( { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Message": [ + { + "name": "data", + "type": "string" + } + ] + }, + "primaryType": "Message", + "domain": { + "name": "example.metamask.io", + "version": "1", + "chainId": "1", + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "data": "Hello!" + } + }); + + let typed_data: TypedData = serde_json::from_value(json).unwrap(); + + let hash = typed_data.encode_eip712().unwrap(); + assert_eq!( + "232cd3ec058eb935a709f093e3536ce26cc9e8e193584b0881992525f6236eef", + hex::encode(&hash[..]) + ); + } + + #[test] + fn test_hash_custom_data_type() { + let json = serde_json::json!( {"domain":{},"types":{"EIP712Domain":[],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person"},{"name":"contents","type":"string"}]},"primaryType":"Mail","message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"}}); + + let typed_data: TypedData = serde_json::from_value(json).unwrap(); + + let hash = typed_data.encode_eip712().unwrap(); + assert_eq!( + "25c3d40a39e639a4d0b6e4d2ace5e1281e039c88494d97d8d08f99a6ea75d775", + hex::encode(&hash[..]) + ); + } + + #[test] + fn test_hash_recursive_types() { + let json = serde_json::json!( { + "domain": {}, + "types": { + "EIP712Domain": [], + "Person": [ + { + "name": "name", + "type": "string" + }, + { + "name": "wallet", + "type": "address" + } + ], + "Mail": [ + { + "name": "from", + "type": "Person" + }, + { + "name": "to", + "type": "Person" + }, + { + "name": "contents", + "type": "string" + }, + { + "name": "replyTo", + "type": "Mail" + } + ] + }, + "primaryType": "Mail", + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!", + "replyTo": { + "to": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "from": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello!" + } + } + }); + + let typed_data: TypedData = serde_json::from_value(json).unwrap(); + + let hash = typed_data.encode_eip712().unwrap(); + assert_eq!( + "0808c17abba0aef844b0470b77df9c994bc0fa3e244dc718afd66a3901c4bd7b", + hex::encode(&hash[..]) + ); + } + + #[test] + fn test_hash_nested_struct_array() { + let json = serde_json::json!({ + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "OrderComponents": [ + { + "name": "offerer", + "type": "address" + }, + { + "name": "zone", + "type": "address" + }, + { + "name": "offer", + "type": "OfferItem[]" + }, + { + "name": "startTime", + "type": "uint256" + }, + { + "name": "endTime", + "type": "uint256" + }, + { + "name": "zoneHash", + "type": "bytes32" + }, + { + "name": "salt", + "type": "uint256" + }, + { + "name": "conduitKey", + "type": "bytes32" + }, + { + "name": "counter", + "type": "uint256" + } + ], + "OfferItem": [ + { + "name": "token", + "type": "address" + } + ], + "ConsiderationItem": [ + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ] + }, + "primaryType": "OrderComponents", + "domain": { + "name": "Seaport", + "version": "1.1", + "chainId": "1", + "verifyingContract": "0x00000000006c3852cbEf3e08E8dF289169EdE581" + }, + "message": { + "offerer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "offer": [ + { + "token": "0xA604060890923Ff400e8c6f5290461A83AEDACec" + } + ], + "startTime": "1658645591", + "endTime": "1659250386", + "zone": "0x004C00500000aD104D7DBd00e3ae0A5C00560C00", + "zoneHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "salt": "16178208897136618", + "conduitKey": "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + "totalOriginalConsiderationItems": "2", + "counter": "0" + } + } + ); + + let typed_data: TypedData = serde_json::from_value(json).unwrap(); + + let hash = typed_data.encode_eip712().unwrap(); + assert_eq!( + "0b8aa9f3712df0034bc29fe5b24dd88cfdba02c7f499856ab24632e2969709a8", + hex::encode(&hash[..]) + ); + } +}