Solidity - EIP-712 Typed Structured Data Hashing and Signing
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 | function permit(address owner, address spender, uint value, uint nonce, uint deadline, uint8 v, bytes32 r, bytes32 s) external { |
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:
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:
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 | struct Mail { |
encodeType
will be
1 | Mail(address from,address to,string contents) |
Notice
uint
andint
must beuin256
andint256
.- Array are denoted by
Type[n]
orType[]
. For example,uint256[2]
oruint256[]
.
When constructing a nested structure, the other structure names follow in alphabetical order. For example:
1 | struct Transaction { |
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:
- Each variable is converted into 32 bytes length data according to the following points.
- If the variable is
struct
, it will be converted tobytes32
by recursive callhashStruct()
. Similarly, if it is astruct
array, it will be converted tobytes32[]
. - If variables are dynamic length
string
,bytes
and array, useabi.encodePacked
to join and performkeccak256
hashing. - 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 | bytes32 internal constant TYPE_HASH = keccak256("Mail(address from,address to,string contents)"); |
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 | struct EIP712Domain { |
- 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 | contract Example { |
Without Defining Struct
You don’t have to actually define struct
in the contract, you can also directly pass the variables into it:
1 | function hashStruct(address from, address to, string memory contents) public pure returns (bytes32) { |
Nest Struct
Use the above Transaction
as an example:
1 | bytes32 internal constant TRANSACTION_TYPE_HASH = keccak256("Transaction(Person from,Person to,Asset asset)Asset(address token,uint256 amount)Person(address wallet,string name)"); |
Array
1 | struct Mail { |
Struct Array
1 | struct Mail { |
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 | import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; |
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 | import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; |
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