什么是EIP712
EIP712是一种特殊的类型化数据签名,与普通签名不同,EIP712的签名数据是结构化的。使用支持EIP712的Dapp进行签名时,Dapp会展示签名消息的结构化详细数据,用户可以对数据进行验证,确认后再进行签名。
实现逻辑
EIP712分为链下签名和链上校验两部分,链下的签名结构定义需要与链上的验证合约保持一致。
验签逻辑则和普通签名相同,通过r,s,v验证公钥是否一致即可。
具体实现
链下签名
一个标准的签名结构如下所示
json
{
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" },
{ "name": "version", "type": "string" },
{ "name": "chainId", "type": "uint256" },
{ "name": "verifyingContract", "type": "address" }
],
"Person": [
{ "name": "name", "type": "string" },
{ "name": "wallet", "type": "address" }
],
"Mail": [
{ "name": "from", "type": "Person" },
{ "name": "to", "type": "Person" },
{ "name": "contents", "type": "string" }
]
},
"primaryType": "Mail",
"domain": {
"name": "MyDapp",
"version": "1",
"chainId": 1,
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
},
"message": {
"from": {
"name": "Alice",
"wallet": "0x1234567890abcdef1234567890abcdef12345678"
},
"to": {
"name": "Bob",
"wallet": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
},
"contents": "Hello, Bob!"
}
}
其中types
是签名信息中出现的数据结构的类型定义,包括固定的EIP712Domain
以及自定义的结构体,在这个示例中是Person
和Mail
。因为EIP712支持嵌套结构,可以看到Mail
中出现了Person
的成员变量。
domain
和message
就是types中定义的结构的具体实现,domain
就是固定的指向EIP712Domain
,其中name
和version
需要与验签合约中定义的一致,chainId
和verifyContract
就是合约部署的链与地址。
而message
在这个示例中就是一个Mail
对象,可以看到primaryType
为Mail
,这代表了message
的对象类型,因为支持嵌套,所以在解析时会从primaryType
开始解析,然后逐步解析内置的其他结构体。在ether.js
中会自动分析primaryType
,所以无需指定。
签名时会按照结构体来向用户展示message
信息。
链上合约
solidity
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract EIP712Storage {
using ECDSA for bytes32;
bytes32 private constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
bytes32 private constant STORAGE_TYPEHASH = keccak256("Person(string name,address wallet)");
bytes32 private DOMAIN_SEPARATOR;
}
在变量中定义了两个TYPEHASH
常量,分别是domain
和message
的type,用于后面生成签名摘要。
solidity
constructor(){
DOMAIN_SEPARATOR = keccak256(abi.encode(
EIP712DOMAIN_TYPEHASH, // type hash
keccak256(bytes("EIP712Storage")), // name
keccak256(bytes("1")), // version
block.chainid, // chain id
address(this) // contract address
));
owner = msg.sender;
}
在构造函数中定义了domain
的name
和version
,因此链下签名中的domain也要保持一致。
solidity
function permitStore(string memory name, bytes memory _signature) public {
// 检查签名长度,65是标准r,s,v签名的长度
require(_signature.length == 65, "invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
// 目前只能用assembly (内联汇编)来从签名中获得r,s,v的值
assembly {
/*
前32 bytes存储签名的长度 (动态数组存储规则)
add(sig, 32) = sig的指针 + 32
等效为略过signature的前32 bytes
mload(p) 载入从内存地址p起始的接下来32 bytes数据
*/
// 读取长度数据后的32 bytes
r := mload(add(_signature, 0x20))
// 读取之后的32 bytes
s := mload(add(_signature, 0x40))
// 读取最后一个byte
v := byte(0, mload(add(_signature, 0x60)))
}
// 获取签名消息hash
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(abi.encode(STORAGE_TYPEHASH, name, msg.sender))
));
address signer = digest.recover(v, r, s); // 恢复签名者
require(signer == msg.sender, "EIP712Storage: Invalid signature"); // 检查签名
}
可以看到在方法中重新生成了一个签名摘要digest
,也就是message hash
,其中 "\x19\x01"
是签名哈希的固定前缀,然后拼接上domain
和message
内容的哈希,再调用recover
恢复出signer
。
此处的recover
方法是openzeppelin
库的语法糖,因为前面通过 using ECDSA for bytes32;
引入了ECDSA,用recover
替代了底层的ecrecover
实现,使校验更方便。
ERC20 Permit
基于EIP712,可以在链下实现对ERC20token的授权,称为ERC20Permit。
即链下实现签名,链上合约验证,验证成功后调用approve
方法。在这种情况下token的owner无需持有gas,只需要在链下签名后将签名给到有gas的B,由B去执行,就可以实现将token授权给B甚至第三方的操作。
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./IERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
/**
* @dev ERC20 Permit 扩展的接口,允许通过签名进行批准,如 https://eips.ethereum.org/EIPS/eip-2612[EIP-2612]中定义。
*
* 添加了 {permit} 方法,可以通过帐户签名的消息更改帐户的 ERC20 余额(参见 {IERC20-allowance})。通过不依赖 {IERC20-approve},代币持有者的帐户无需发送交易,因此完全不需要持有 Ether。
*/
contract ERC20Permit is ERC20, IERC20Permit, EIP712 {
mapping(address => uint) private _nonces;
bytes32 private constant _PERMIT_TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
/**
* @dev 初始化 EIP712 的 name 以及 ERC20 的 name 和 symbol
*/
constructor(string memory name, string memory symbol) EIP712(name, "1") ERC20(name, symbol){}
/**
* @dev See {IERC20Permit-permit}.
*/
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual override {
// 检查 deadline
require(block.timestamp <= deadline, "ERC20Permit: expired deadline");
// 拼接 Hash
bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));
bytes32 hash = _hashTypedDataV4(structHash);
// 从签名和消息计算 signer,并验证签名
address signer = ECDSA.recover(hash, v, r, s);
require(signer == owner, "ERC20Permit: invalid signature");
// 授权
_approve(owner, spender, value);
}
/**
* @dev See {IERC20Permit-nonces}.
*/
function nonces(address owner) public view virtual override returns (uint256) {
return _nonces[owner];
}
/**
* @dev See {IERC20Permit-DOMAIN_SEPARATOR}.
*/
function DOMAIN_SEPARATOR() external view override returns (bytes32) {
return _domainSeparatorV4();
}
/**
* @dev "消费nonce": 返回 `owner` 当前的 `nonce`,并增加 1。
*/
function _useNonce(address owner) internal virtual returns (uint256 current) {
current = _nonces[owner];
_nonces[owner] += 1;
}
}