本篇文章說明 EIP-712 的標準,以及如何使用 Solidity 實作 EIP-712 的智能合約簽章驗證。

簡介

在之前的文章介紹過一些簽章和檢驗的方式,而實際運用的時候可能會簽署許多資料來使用,例如這個例子:

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;
// 檢驗或使用 spender...
}

這裡使用多種資料,最後簽署的是 messageHash,如果使用 Metamask 之類的錢包,會看到下面的提示:
簽署

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

例如

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

encodeType 結果為

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

注意

  • uintint 必須寫成 uin256int256
  • 陣列寫作 Type[n]Type[],例如 uint256[2]uint256[]

巢狀結構時,把其他結構名稱照字母順序排列接在後面,例如

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 結果為

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

Asset 排在 Person 前面。

encodeData

定義結構化資料如何編碼,整理為以下幾點:

  1. 每個變數都轉換為 32 bytes 長度的資料,依照後面幾點進行。
  2. 變數為 struct時,遞迴呼叫 hashStruct() 轉為 bytes32,同理 struct 陣列時,轉為 bytes32[]
  3. 變數為動態長度 stringbytes 和陣列時,使用 abi.encodePacked 接合並進行 keccak256 雜湊。
  4. 其他一般型別前面補零成為 32 bytes 長度,在 Solidity 中使用 abi.encode 可達成。

上面 Mail 例子的 encodeData

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))
)

後面章節有更多範例。

eip712Domain

最後回到 domainSeparator 中的 eip712Domain 定義,他是另一組預定義結構化的資料,用來區分不同使用範圍的簽章,可防止重放攻擊。包含以下欄位:

1
2
3
4
5
6
7
struct EIP712Domain {
string name;
string version;
uint256 chainId;
address verifyingContract;
// bytes32 salt;
}
  • name:自定的名稱。
  • version:自訂的版號。
  • chainId:鏈 ID,智能合約一般使用 block.chainid 取得。
  • verifyingContract:驗證簽章的合約地址,智能合約中一般使用 address(this) 取得。
  • salt:標準中定義了這個欄位,當如果還有碰撞的可能,可以再用這個欄位區別。不過一般沒有使用,目前實務上沒有包含這欄位。

encodeType

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

設定好 nameversion 等資料後,將這組資料一樣丟到 hashStruct 就可以得到 domainSeparator

範例

完整範例

一個完整的合約範例如下:

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);
}
}

不定義 struct

合約內不一定要真的定義 struct,也可以直接把變數帶入:

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);
}

巢狀結構

以上面的 Transaction 為例:

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
)
);
}

陣列

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))
)
);
}

結構陣列

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 EIP712 套件 來處理此類型驗證簽章。首先引用並繼承 EIP712,在建構子的時候定義 nameversion

1
2
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);

上面的範例使用套件後改寫如下:

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);
}
}

延伸閱讀

Solidity 智能合約 - ecrecover 簽章檢驗
使用 Web3.js 進行 EIP-712 類型結構化資料簽名
使用 Ethers.js 進行 EIP-712 類型結構化資料簽名