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 <me@gakonst.com>
This commit is contained in:
parent
9cc0fb0036
commit
3d76ce816a
|
@ -66,3 +66,5 @@ pub use fee::*;
|
|||
|
||||
mod other;
|
||||
pub use other::OtherFields;
|
||||
|
||||
pub mod serde_helpers;
|
||||
|
|
|
@ -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<Numeric> 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<StringifiedNumeric> for U256 {
|
||||
type Error = FromDecStrErr;
|
||||
|
||||
fn try_from(value: StringifiedNumeric) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
StringifiedNumeric::U256(n) => Ok(n),
|
||||
StringifiedNumeric::Num(n) => Ok(U256::from(n)),
|
||||
StringifiedNumeric::String(s) => {
|
||||
if let Ok(val) = s.parse::<u128>() {
|
||||
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<U256, D::Error>
|
||||
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<Option<U256>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let num = match Option::<Numeric>::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<U256, D::Error>
|
||||
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<LenientBlockNumber> 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: <https://github.com/foundry-rs/foundry/issues/1868>
|
||||
///
|
||||
/// N.B.: geth does not support ints in `eth_getBlockByNumber`
|
||||
pub fn lenient_block_number<'de, D>(deserializer: D) -> Result<BlockNumber, D::Error>
|
||||
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<BlockNumber, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let num =
|
||||
<[LenientBlockNumber; 1]>::deserialize(deserializer)?.into_iter().next().unwrap().into();
|
||||
Ok(num)
|
||||
}
|
|
@ -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<String, Vec<Eip712DomainType>>;
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<U256>,
|
||||
|
||||
/// 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<Address>,
|
||||
|
||||
/// 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<T: Eip712 + Clone> EIP712WithDomain<T> {
|
||||
pub fn new(inner: T) -> Result<Self, Eip712Error> {
|
||||
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<T: Eip712 + Clone> Eip712 for EIP712WithDomain<T> {
|
|||
}
|
||||
|
||||
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<EIP712Domain, Self::Error> {
|
||||
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::<u64>().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<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct TypedDataHelper {
|
||||
domain: EIP712Domain,
|
||||
types: Types,
|
||||
#[serde(rename = "primaryType")]
|
||||
primary_type: String,
|
||||
message: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[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<EIP712Domain, Self::Error> {
|
||||
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 <https://github.com/MetaMask/eth-sig-util>
|
||||
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<Vec<Token>, 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<String, Eip712Error> {
|
||||
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::<Vec<_>>()
|
||||
.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<Token, Eip712Error> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
|
||||
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 <https://github.com/gakonst/ethers-rs/blob/master/ethers-contract/ethers-contract-derive/src/lib.rs#L600>
|
||||
/// 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::<Vec<Token>>(),
|
||||
)))),
|
||||
Token::Tuple(tuple) => {
|
||||
let tokens = tuple.into_iter().map(encode_eip712_type).collect::<Vec<Token>>();
|
||||
let encoded = encode(&tokens);
|
||||
Token::Uint(U256::from(keccak256(encoded)))
|
||||
}
|
||||
_ => {
|
||||
// Return the ABI encoded token;
|
||||
token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted tests from <https://github.com/MetaMask/eth-sig-util/blob/main/src/sign-typed-data.test.ts>
|
||||
#[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[..])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue