This article explains the EIP-712 standard and how to use Solidity to implement EIP-712 in smart contract.

Introduction

In the previous article, we introduced some methods of signing and verification. In practice, we may sign many data. For example:

1
2
3
4
5
6
7
8
9
function permit(address owner, address spender, uint value, uint nonce, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, "signature expired");
require(!usedNonces[nonce], "used nonce");
bytes32 messageHash = abi.encodePacked(block.chainid, address(this), spender, value, nonce, deadline);
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
require(owner == ecrecover(hash , v, r, s), "invalid signature");
usedNonces[nonce] = true;
// ...
}

Various data are used here, and the last data to be signed is messageHash. If you use a wallet such as Metamask, you will see the following pop up:
Sign

For the user, the actual content of the signature is unknown. To solve this problem, the EIP-712 standard was proposed to be able to sign and preview structured information. For example, the signed content is as follows:
EIP-712 Sign

Specifications

Make some slight modifications here to explain the definition in a simpler way. We know that the final content to be verified will be a hash as follows:

1
ecrecover(hash , v, r, s);

And EIP-712 defines how this hash comes from.

hash

The original signed information is defined as follows:

1
hash = keccak256("\x19\x01" + domainSeparator + hashStruct(message))

domainSeparator

The definition is as follows:

1
domainSeparator = hashStruct(eip712Domain)

hashStruct

Hash structured data, the definition is as follows:

1
hashStruct(s) = keccak256(typeHash of s + encodeData(data of s))

The hashed content includes the type description typeHash and all data contents.

typeHash

For different structured data, there will be different typeHash

1
typeHash = keccak256(encodeType(type of s))

encodeType

The string describing the structure has a more complicated definition and the format is as follows:

1
StructureName(type variableName, type variableName2,...)

For example

1
2
3
4
5
struct Mail {
address from;
address to;
string contents;
}

encodeType will be

1
Mail(address from,address to,string contents)

Notice

  • uint and int must be uin256 and int256.
  • Array are denoted by Type[n] or Type[]. For example, uint256[2] or uint256[].

When constructing a nested structure, the other structure names follow in alphabetical order. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Transaction {
Person from;
Person to;
Asset asset;
}

struct Person {
address wallet;
string name;
}

struct Asset {
address token;
uint256 amount;
}

encodeType will be

1
Transaction(Person from,Person to,Asset asset)Asset(address token,uint256 amount)Person(address wallet,string name)

Asset comes before Person.

encodeData

Define how to encode structured data, organized into the following points:

  1. Each variable is converted into 32 bytes length data according to the following points.
  2. If the variable is struct, it will be converted to bytes32 by recursive call hashStruct(). Similarly, if it is a struct array, it will be converted to bytes32[].
  3. If variables are dynamic length string, bytes and array, use abi.encodePacked to join and perform keccak256 hashing.
  4. Other general types are padded with leading zeros in front to become 32 bytes. It can be achieved by using abi.encode in Solidity.

encodeData for the above example Mail:

1
2
3
4
5
6
7
8
bytes32 internal constant TYPE_HASH = keccak256("Mail(address from,address to,string contents)");

abi.encode(
TYPE_HASH,
mail.from,
mail.to,
keccak256(abi.encodePacked(mail.content))
)

There are more examples in later sections.

eip712Domain

Finally, back to the eip712Domain definition in domainSeparator. It is another set of predefined structured data that is used to distinguish signatures of different scopes and prevent replay attacks. Contains the following fields:

1
2
3
4
5
6
7
struct EIP712Domain {
string name;
string version;
uint256 chainId;
address verifyingContract;
// bytes32 salt;
}
  • name: Customized name.
  • version: Customized version number.
  • chainId: Chain ID. Smart contracts usually use block.chainid to obtain it.
  • verifyingContract: The contract address for verifying the signature. It is usually obtained by using address(this) in smart contracts.
  • salt: This field is defined in the standard. If there is still a possibility of collision, you can use this field to distinguish. However, it is usually not used, and currently this field is not included in practice.

encodeType will be

1
EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)

After setting the data such as name and version, pass this data into hashStruct to get domainSeparator.

Examples

Complete Example

A complete contract example is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
contract Example {
struct Mail {
address from;
address to;
string contents;
}

bytes32 public immutable domainSeparator;

// 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f
bytes32 private constant EIP712DOMAIN_TYPE_HASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);

// 0x536e54c54e6699204b424f41f6dea846ee38ac369afec3e7c141d2c92c65e67f
bytes32 private constant MAIL_TYPE_HASH = keccak256(
"Mail(address from,address to,string contents)"
);

constructor() {
domainSeparator = keccak256(
abi.encode(
EIP712DOMAIN_TYPE_HASH,
keccak256("Example"),
keccak256("1"),
block.chainid,
address(this)
)
);
}

function hashStruct(Mail memory mail) public pure returns (bytes32) {
return keccak256(
abi.encode(
MAIL_TYPE_HASH,
mail.from,
mail.to,
keccak256(abi.encodePacked(mail.contents))
)
);
}

function toTypedDataHash(bytes32 structHash) public view returns (bytes32) {
return keccak256(abi.encodePacked(
"\x19\x01",
domainSeparator,
structHash
));
}

function recover(Mail memory mail, uint8 v, bytes32 r, bytes32 s) public view returns (address) {
bytes32 structHash = hashStruct(mail);
bytes32 hash = toTypedDataHash(structHash);
return ecrecover(hash , v, r, s);
}
}

Without Defining Struct

You don’t have to actually define struct in the contract, you can also directly pass the variables into it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function hashStruct(address from, address to, string memory contents) public pure returns (bytes32) {
return keccak256(
abi.encode(
MAIL_TYPEHASH,
from,
to,
keccak256(abi.encodePacked(contents))
)
);
}

function recover(address from, address to, string memory contents, uint8 v, bytes32 r, bytes32 s) public view returns (address) {
bytes32 structHash = hashStruct(from, to, contents);
bytes32 hash = toTypedDataHash(structHash);
return ecrecover(hash , v, r, s);
}

Nest Struct

Use the above Transaction as an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
bytes32 internal constant TRANSACTION_TYPE_HASH = keccak256("Transaction(Person from,Person to,Asset asset)Asset(address token,uint256 amount)Person(address wallet,string name)");
bytes32 internal constant PERSON_TYPE_HASH = keccak256("Person(address wallet,string name)");
bytes32 internal constant ASSET_TYPE_HASH = keccak256("Asset(address token,uint256 amount)");

function hashStructOfTransaction(Transaction memory transaction) internal pure returns(bytes32) {
return keccak256(
abi.encode(
TRANSACTION_TYPE_HASH,
hashStructOfPerson(transaction.from),
hashStructOfPerson(transaction.to),
hashStructOfAsset(transaction.asset)
)
);
}

function hashStructOfPerson(Person memory person) internal pure returns(bytes32) {
return keccak256(
abi.encode(
PERSON_TYPE_HASH,
person.wallet,
keccak256(abi.encodePacked(person.name))
)
);
}

function hashStructOfAsset(Asset memory asset) internal pure returns(bytes32) {
return keccak256(
abi.encode(
ASSET_TYPE_HASH,
asset.token,
asset.amount
)
);
}

Array

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Mail {
address from;
address[] to;
string contents;
}

bytes32 internal constant TYPE_HASH = keccak256("Mail(address from,address[] to,string contents)");

function hashStruct(Mail memory mail) internal pure returns(bytes32) {
return keccak256(
abi.encode(
TYPE_HASH,
mail.from,
keccak256(abi.encodePacked(mail.to)),
keccak256(abi.encodePacked(mail.contents))
)
);
}

Struct Array

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct Mail {
Person from;
Person[] to;
string contents;
}

struct Person {
address wallet;
string name;
}

bytes32 internal constant MAIL_TYPE_HASH = keccak256("Mail(Person from,Person[] to,string contents)Person(address wallet,string name)");
bytes32 internal constant PERSON_TYPE_HASH = keccak256("Person(address wallet,string name)");

function hashStructOfMail(Mail memory mail) internal pure returns(bytes32) {
bytes32[] memory encodeData = new bytes32[](mail.to.length);
for (uint i; i < mail.to.length; ++i) {
encodeData[i] = hashStructOfPerson(mail.to[i]);
}

return keccak256(
abi.encode(
MAIL_TYPE_HASH,
hashStructOfPerson(mail.from),
keccak256(abi.encodePacked(encodeData)),
keccak256(abi.encodePacked(mail.contents))
)
);
}

function hashStructOfPerson(Person memory person) internal pure returns(bytes32) {
return keccak256(
abi.encode(
PERSON_TYPE_HASH,
person.wallet,
keccak256(abi.encodePacked(person.name))
)
);
}

OpenZeppelin Library

We can also use the OpenZeppelin EIP712 to handle this type of signature. First, import and inherit EIP712, and set name and version in constructor:

1
2
3
4
5
6
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract Example is EIP712 {
constructor() EIP712("Example", "1") {
}
}

The library will generate domainSeparator and save it. You only need to implement the hashStruct by yourself. Finally, use the following function to generate the hash to verify:

1
function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32);

Write the above example as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract Example is EIP712 {
struct Mail {
address from;
address to;
string contents;
}

// 0x536e54c54e6699204b424f41f6dea846ee38ac369afec3e7c141d2c92c65e67f
bytes32 private constant MAIL_TYPEHASH = keccak256(
"Mail(address from,address to,string contents)"
);

constructor() EIP712("Example", "1") {
}

function hashStruct(Mail memory mail) public pure returns (bytes32) {
return keccak256(
abi.encode(
MAIL_TYPEHASH,
mail.from,
mail.to,
keccak256(abi.encodePacked(mail.contents))
)
);
}

function recover(Mail memory mail, uint8 v, bytes32 r, bytes32 s) public view returns (address) {
bytes32 structHash = hashStruct(mail);
bytes32 hash = _hashTypedDataV4(structHash);
return ecrecover(hash , v, r, s);
}
}

Further Reading

Solidity - ecrecover
Using Web3.js to Sign and Recover EIP-712 Typed Structured Data
Using Ethers.js to Sign and Recover EIP-712 Typed Structured Data