EIP-712: Typed structured data hashing and signing
EIP-712
是一种对类型化、结构化数据进行哈希和签名的标准。
如果仅是字符串签名,那么使用 EIP-191 则是一种可以解决的方式。然而,在现实世界中,有很多复杂结构化的消息。虽然也可以将这些结构化的消息转成字符串再签名,但却缺失了安全性和易读性。
而通过 EIP-712
,开发者能够以一种可验证和安全的方式对结构化数据进行签名,使得用户在签名消息时能够看到清晰、结构化的信息,而不仅仅是一个不透明的十六进制字符串。
DOMAIN_SEPARATOR
DOMAIN_SEPARATOR
称作域分隔符,用于区分不同的签名域,以避免不同应用或服务间的签名冲突和混淆。计算公式如下:
domainSeparator = hashStruct(eip712Domain)
实际是调用 hashStruct
对 eip712Domain
结构进行哈希计算返回 32 字节的数据。下面将分别介绍这两部分
EIP712Domain
EIP712Domain
是一个结构体。通常包括以下字段:
string name
: 应用或服务的名称string version
: 应用或服务的版本uint256 chainId
: 以太坊网络的链 ID,以区分不同的以太坊网络。如果与当前活动链不匹配,则拒绝签名address verifyingContract
: 验证签名的合约地址bytes32 salt
: 用于域分隔的额外数据,通常是一个随机值,以增加安全性。可选
hashStruct
hashStruct
用于对结构体进行哈希计算。公式如下:
hashStruct(s) = keccak256(typeHash + encodeData(s))
其中:
-
typeHash
等于keccak256(encodeType(typeOf(s)))
信息encodeType
用于对结构体的类型编码为字符串。对结构体EIP712Domain
而言encodeType
就等于"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
得到
typeHash
等于keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
-
encodeData
将结构体实例的成员值按它们在类型中出现的顺序连接起来。每个编码的成员值长度为 32 字节。基本类型的值有各自的编码方式,例如布尔值、地址和整数值。动态值如bytes
和string
是通过它们内容的keccak256
哈希值进行编码,数组值是通过它们内容的encodeData
连接后的keccak256
哈希值编码,而结构体值则通过递归地应用hashStruct(value)
来编码。
因此当结构体类型为 EIP712Domain
时:
typeHash = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
encodeData = abi.encode(keccak256(name), keccak256(version),chainId, verifyingContract)
因此 DOMAIN_SEPARATOR
最终计算公式如下:
// DOMAIN_SEPARATOR = hashStruct(eip712Domain)
// hashStruct(s) = keccak256(typeHash + encodeData(s))
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(name),
keccak256(version),
chainId,
verifyingContract
)
)
JSON RPC
在以太坊 JSON-RPC 中添加了 eth_signTypedData
方法。
eth_signTypedData
该方法使用以下方式计算以太坊特定的签名:
sign(keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)))
请求参数:
Address
- 将对消息进行签名的账户地址TypedData
- 要签名的结构化数据
TypedData
是一个包含类型信息、域分隔符参数和消息对象的 JSON 对象。例如:
{
"type": "object",
"properties": {
"types": {
"type": "object",
"properties": {
"EIP712Domain": { "type": "array" }
},
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "type": "string" }
},
"required": ["name", "type"]
}
},
"required": ["EIP712Domain"]
},
"primaryType": { "type": "string" },
"domain": { "type": "object" },
"message": { "type": "object" }
},
"required": ["types", "primaryType", "domain", "message"]
}
返回结果是一个签名后的数据,与 eth_sign
一样,它是一个以 0x 开头的十六进制编码的 129 字节数组。
- 0-64 字节: 参数
r
- 64-128 字节: 参数
s
- 最后一个字节为
v
参数
示例请求:
curl -X POST --data '{"jsonrpc":"2.0","method":"eth_signTypedData","params":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", {"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":"Ether Mail","version":"1","chainId":1,"verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},"message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"}}],"id":1}'
返回
{
"id": 1,
"jsonrpc": "2.0",
"result": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c"
}
personal_signTypedData
请求参数上在 eth_signTypedData
的基础上追加密码参数。
实践
合约实现
- 未使用openzeppelin库
- 使用openzeppelin库
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.19;
contract EIP712OrderVerify {
// 签名的结构体
struct MakerOrder {
bool isOrderAsk;
address maker;
address token;
uint256 price;
uint256 amount;
}
bytes32 public immutable DOMAIN_SEPARATOR;
constructor() {
bytes32 EIP712DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
DOMAIN_SEPARATOR = keccak256(
abi.encode(
EIP712DOMAIN_TYPEHASH,
keccak256("EIP712OrderVerify"),
keccak256("1"),
block.chainid,
address(this)
)
);
}
// 计算待签名的结构体的hash
function hashStruct(MakerOrder memory order) public pure returns (bytes32) {
bytes32 TYPE_HASH = keccak256(
"MakerOrder(bool isOrderAsk, address maker, address token, uint256 price, uint256 amount)"
);
return keccak256(
abi.encode(
TYPE_HASH,
order.isOrderAsk,
order.maker,
order.token,
order.price,
order.amount
)
);
}
// 验证签名是否有效
function verify(
MakerOrder memory order, uint8 v, bytes32 r, bytes32 s
) public view returns (bool) {
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
hashStruct(order)
));
return ecrecover(digest, v, r, s) == order.maker;
}
}
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract EIP712OrderVerify {
// 签名的结构体
struct MakerOrder {
bool isOrderAsk;
address maker;
address token;
uint256 price;
uint256 amount;
}
bytes32 public immutable DOMAIN_SEPARATOR;
constructor() {
bytes32 EIP712DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
DOMAIN_SEPARATOR = keccak256(
abi.encode(
EIP712DOMAIN_TYPEHASH,
keccak256("EIP712OrderVerify"),
keccak256("1"),
block.chainid,
address(this)
)
);
}
// 验证签名是否有效
function verify(
MakerOrder memory order, uint8 v, bytes32 r, bytes32 s
) public view returns (bool) {
bytes32 MakerOrderHash = keccak256(
"MakerOrder(bool isOrderAsk, address maker, address token, uint256 price, uint256 amount)"
);
return SignatureChecker.isValidSignatureNow(
order.maker,
ECDSA.toTypedDataHash(DOMAIN_SEPARATOR, MakerOrderHash),
abi.encodePacked(r, s, v)
);
}
}
ecrecover
是 Solidity
内置函数,可以用于从签名中恢复公钥地址。
生成 r、s、v
使用 wagmi
框架
import { signTypedData } from '@wagmi/core'
import { useAccount, useChainId } from 'wagmi'
import type { Hex, TypedDataDomain } from 'viem'
const onSignTypedData = async () => {
const domain: TypedDataDomain = {
name: 'EIP712OrderVerify',
version: '1',
chainId: chainId,
verifyingContract: '0x5b34a1420D906F2026d053D995A75C26907b0f64'
}
const types = {
Order: [
{ name: 'isOrderAsk', type: 'bool' },
{ name: 'maker', type: 'address' },
{ name: 'token', type: 'address' },
{ name: 'price', type: 'uint256' },
{ name: 'amount', type: 'uint256' }
]
}
const message = {
isOrderAsk: true,
maker: address,
token: '0xC1E1C0Ab645Bd3C3156b20953784992013FDa98d',
price: 100,
amount: 10000000
}
let signature = await signTypedData({
domain: domain,
message: message,
primaryType: 'Order',
types: types
})
signature = signature.slice(2) as Hex
const r = `0x${signature.slice(0, 64)}`
const s = `0x${signature.slice(64, 128)}`
const v = `0x${signature.slice(128, 130)}`
return {
r,
s,
v
}
}