本篇文章說明 EIP-712 的標準,以及如何使用 Solidity 實作 EIP-712 的智能合約簽章驗證。
簡介
在之前的文章介紹過一些簽章和檢驗的方式,而實際運用的時候可能會簽署許多資料來使用,例如這個例子:
| 12
 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;
 
 }
 
 | 
這裡使用多種資料,最後簽署的是 messageHash,如果使用 Metamask 之類的錢包,會看到下面的提示:

對於使用者來說,並不知道簽署的實際內容是什麼。為了解決這個問題,提出 EIP-712 的標準,能夠簽署和預覽結構的資料。例如看到簽署的內容如下:

規格和定義
這邊稍微做一些修改,以比較簡單的方式來說明定義。我們知道最後要被驗證的內容會是一個 hash 如下:
| 1
 | ecrecover(hash , v, r, s);
 | 
而 EIP-712 就是定義這個 hash 怎麼來的。
hash
為當初簽署的資料,定義如下:
| 1
 | hash = keccak256("\x19\x01" + domainSeparator + hashStruct(message))
 | 
domainSeparator
定義如下:
| 1
 | domainSeparator = hashStruct(eip712Domain)
 | 
hashStruct
為把結構化資料進行雜湊,定義如下:
| 1
 | hashStruct(s) = keccak256(typeHash of s + encodeData(data of s))
 | 
進行雜湊的內容包含型別描述 typeHash 和所有資料內容。
typeHash
針對不同的結構化資料,會有不同的 typeHash
| 1
 | typeHash = keccak256(encodeType(type of s))
 | 
encodeType
描述結構的字串,定義比較複雜,格式如下:
| 1
 | 結構名稱(型別 變數名稱,型別 變數名稱2,...)
 | 
例如
| 12
 3
 4
 5
 
 | struct Mail {address from;
 address to;
 string contents;
 }
 
 | 
encodeType 結果為
| 1
 | Mail(address from,address to,string contents)
 | 
注意
- uint和- int必須寫成- uin256和- int256。
- 陣列寫作 Type[n]或Type[],例如uint256[2]或uint256[]。
巢狀結構時,把其他結構名稱照字母順序排列接在後面,例如
| 12
 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 結果為
| 1
 | Transaction(Person from,Person to,Asset asset)Asset(address token,uint256 amount)Person(address wallet,string name)
 | 
Asset 排在 Person 前面。
encodeData
定義結構化資料如何編碼,整理為以下幾點:
- 每個變數都轉換為 32 bytes 長度的資料,依照後面幾點進行。
- 變數為 struct時,遞迴呼叫hashStruct()轉為bytes32,同理struct陣列時,轉為bytes32[]。
- 變數為動態長度 string、bytes和陣列時,使用abi.encodePacked接合並進行keccak256雜湊。
- 其他一般型別前面補零成為 32 bytes 長度,在 Solidity 中使用 abi.encode可達成。
上面 Mail 例子的 encodeData:
| 12
 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))
 )
 
 | 
後面章節有更多範例。
eip712Domain
最後回到 domainSeparator 中的 eip712Domain 定義,他是另一組預定義結構化的資料,用來區分不同使用範圍的簽章,可防止重放攻擊。包含以下欄位:
| 12
 3
 4
 5
 6
 7
 
 | struct EIP712Domain {string name;
 string version;
 uint256 chainId;
 address verifyingContract;
 
 }
 
 | 
- name:自定的名稱。
- version:自訂的版號。
- chainId:鏈 ID,智能合約一般使用 block.chainid取得。
- verifyingContract:驗證簽章的合約地址,智能合約中一般使用 address(this)取得。
- salt:標準中定義了這個欄位,當如果還有碰撞的可能,可以再用這個欄位區別。不過一般沒有使用,目前實務上沒有包含這欄位。
encodeType 為
| 1
 | EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
 | 
設定好 name 和 version 等資料後,將這組資料一樣丟到 hashStruct 就可以得到 domainSeparator。
範例
完整範例
一個完整的合約範例如下:
| 12
 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;
 
 
 bytes32 private constant EIP712DOMAIN_TYPE_HASH = keccak256(
 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
 );
 
 
 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);
 }
 }
 
 | 
不定義 struct
合約內不一定要真的定義 struct,也可以直接把變數帶入:
| 12
 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);
 }
 
 | 
巢狀結構
以上面的 Transaction 為例:
| 12
 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
 )
 );
 }
 
 | 
陣列
| 12
 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))
 )
 );
 }
 
 | 
結構陣列
| 12
 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 EIP712 套件 來處理此類型驗證簽章。首先引用並繼承 EIP712,在建構子的時候定義 name 和 version:
| 12
 3
 4
 5
 6
 
 | import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
 contract Example is EIP712 {
 constructor() EIP712("Example", "1") {
 }
 }
 
 | 
套件會產生 domainSeparator 存起來。自己只需要處理 hashStruct,最後使用下面的函式生成要被檢驗的 hash:
| 1
 | function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32);
 | 
上面的範例使用套件後改寫如下:
| 12
 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;
 }
 
 
 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);
 }
 }
 
 | 
延伸閱讀
Solidity 智能合約 - ecrecover 簽章檢驗
使用 Web3.js 進行 EIP-712 類型結構化資料簽名
使用 Ethers.js 進行 EIP-712 類型結構化資料簽名