跳到主要内容

EIP-712: Typed structured data hashing and signing

原文

EIP-712 是一种对类型化、结构化数据进行哈希和签名的标准。

如果仅是字符串签名,那么使用 EIP-191 则是一种可以解决的方式。然而,在现实世界中,有很多复杂结构化的消息。虽然也可以将这些结构化的消息转成字符串再签名,但却缺失了安全性和易读性。

而通过 EIP-712,开发者能够以一种可验证和安全的方式对结构化数据进行签名,使得用户在签名消息时能够看到清晰、结构化的信息,而不仅仅是一个不透明的十六进制字符串。

DOMAIN_SEPARATOR

DOMAIN_SEPARATOR 称作域分隔符,用于区分不同的签名域,以避免不同应用或服务间的签名冲突和混淆。计算公式如下:

domainSeparator = hashStruct(eip712Domain)

实际是调用 hashStructeip712Domain 结构进行哈希计算返回 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 字节。基本类型的值有各自的编码方式,例如布尔值、地址和整数值。动态值如 bytesstring 是通过它们内容的 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 的基础上追加密码参数。

实践

合约实现

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

ecrecoverSolidity 内置函数,可以用于从签名中恢复公钥地址。

生成 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
}
}