EIP712 实践
在数字签名的场景中,签名者将信息用私钥加密,然后公布公钥;验证者使用公钥将加密后的信息解密,并与原始信息比对(一般签名对象为原始消息的[散列值])。在这个流程里,当用户用web3钱包对消息进行签名时,普通用户通常无法看到有意义的结构化数据,而可能是一串无法识别的十六进制字符串(增加用户被诈骗的风险),如下图红色椭圆所示区域: 在EIP712规范中,提供了一种对(用户可见的)类型结构化数据进行签名的方案,当用户对消息进行签名时,DAPP应该将有意义的类型结构化数据展示给用户查看,如下图红色椭圆所示区域: 在EIP712之前,参与签名的输入信息包含交易和字节串。而在EIP712里,参与签名的输入信息包括三部分:交易𝕋、字节串𝔹⁸ⁿ、结构化数据𝕊。实现EIP712的DAPP,通常应该将类型结构化数据展示给用户。
EIP712的典型应用流程概括如下图: (图中左侧对应拥有私钥的用户,右侧对应智能合约。用户使用私钥对消息签名,智能合约收到签名消息后,从签名消息中恢复出地址,并对地址进行验证"是否为合法用户")
下文所列为部分代码片段。openzeppelin已经实现了EIP712规范,因此我们的样例代码是直接依赖openzeppelin实现的,完整的参考代码位于:https://github.com/zhtkeepup/eip712practice
以下是Solidity开发框架foundry里的测试代码,用于模拟用户端代码:
function test_permitDoSomething() public {
vm.startPrank(admin);
uint256 nn1 = eip712Practice.number();
permit = Eip712Practice.PermitData({
signer: admin,
message1: 778899,
message2: 112233,
nonce: 0
});
// hashing typed structured data
bytes32 digest = eip712Practice.getTypedDataHash(permit);
// signing with private key and typed data hash.
(v, r, s) = vm.sign(aPrivateKey, digest);
// call smart contrat with signature
eip712Practice.permitDoSomething(
permit.signer,
permit.message1,
permit.message2,
v,
r,
s
);
//
uint256 nn2 = eip712Practice.number();
assertEq(nn1 + 1, nn2);
console.log("number,", nn1, nn2);
}
以下是Solidity智能合约里的代码,function eip712permit
对用户的签名进行认证(用户端的签名操作并未上链,因此用户签名也称为离线签名):
contract Eip712Practice is EIP712, Nonces {
struct PermitData {
address signer;
uint256 message1;
uint256 message2;
uint256 nonce;
}
bytes32 private constant PERMIT_TYPEHASH =
keccak256(
"eip712permit(address signer,uint256 message1,uint256 message2,uint256 nonce)"
);
string private constant SIGNING_DOMAIN_NAME = "Eip712Practice";
string private constant SIGNING_DOMAIN_VERSION = "1";
error ERC2612InvalidSigner(address signer, address owner);
uint256 public number;
constructor() EIP712(SIGNING_DOMAIN_NAME, SIGNING_DOMAIN_VERSION) {}
/**
generate hash by 5 element:
1.keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), (named TYPE_HASH)
2. SIGNING_DOMAIN_NAME,
3. SIGNING_DOMAIN_VERSION,
4. block.chainid,
5. address(this),
*/
function domainSeparator() private view returns (bytes32) {
return _domainSeparatorV4();
}
function getTypedDataHash(
PermitData memory _permit
) public view returns (bytes32) {
return
// same as: keccak256(abi.encodePacked("\x19\x01",DOMAIN_SEPARATOR,keccak256(...)));
MessageHashUtils.toTypedDataHash(
domainSeparator(),
keccak256(
abi.encode(
PERMIT_TYPEHASH,
_permit.signer,
_permit.message1,
_permit.message2,
_permit.nonce
)
)
);
}
function eip712permit(
address signer,
uint256 message1,
uint256 message2,
uint8 v,
bytes32 r,
bytes32 s
) private {
PermitData memory pd = PermitData({
signer: signer,
message1: message1,
message2: message2,
nonce: _useNonce(signer)
});
bytes32 hash = getTypedDataHash(pd);
address recoveredSigner = ECDSA.recover(hash, v, r, s);
if (signer != recoveredSigner) {
revert ERC2612InvalidSigner(recoveredSigner, signer);
}
}
event DoSomething(address signer, uint256 message1, uint256 message2);
function permitDoSomething(
address signer,
uint256 message1,
uint256 message2,
uint8 v,
bytes32 r,
bytes32 s
) external {
eip712permit(signer, message1, message2, v, r, s);
// do something...
number++;
emit DoSomething(signer, message1, message2);
}
}
digest
的值应该与验证合约中变量hash
的值一样,即digest@test_permitDoSomething == hash@eip712permit
;digest
(或hash
)而指定的数据格式,应该完全符合EIP712的规范.domainSeparator()
对应EIP712规范的"Definition of domainSeparator"部分,其结果依赖:SIGNING_DOMAIN_NAME
、SIGNING_DOMAIN_VERSION
、当前区块链的链ID、当前合约的地址;domainSeparator()
、PERMIT_TYPEHASH
、业务参数值;PERMIT_TYPEHASH
与对应的业务参数值,并无实际关联,只是人眼看起来有联系而已。但事实上,这个“人眼看起来的联系”是属于EIP712规范的一部分;以下是javascript里使用viem实现EIP712中的用户签名的相关代码:
import {
getContract,
formatEther,
parseEther,
encodeAbiParameters,
encodeFunctionData,
keccak256,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { createPublicClient, http, createWalletClient } from "viem";
import { sepolia, mainnet, localhost } from "viem/chains";
import { abiPermit2DoSomething } from "./abi/Eip712PracticeAbi.js";
const walletClient = createWalletClient({
chain: localhost,
transport: http("http://127.0.0.1:8545"),
});
const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const domain = {
name: "Eip712Practice",
version: "1",
chainId: 1337, //
verifyingContract: CONTRACT_ADDRESS, //,
};
const types = {
eip712permit: [
{ name: "signer", type: "address" },
{ name: "message1", type: "uint256" },
{ name: "message2", type: "uint256" },
{ name: "nonce", type: "uint256" },
],
};
// this key is the first of "anvil's test key"
const privateKey =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const account = privateKeyToAccount(privateKey);
async function signAuth(message1, message2, nonce) {
const signature = await walletClient.signTypedData({
account,
domain,
types,
primaryType: "eip712permit",
message: {
signer: account.address,
message1: message1,
message2: message2,
nonce: nonce,
},
});
console.log("account.address:", account.address);
// 0x744cef81591296eaf103706f0f4388d5284b3383fd3b087374ce90ecf13c05c82003bf9436cb29da6220beca227076e5e07bb03f8b49a71af98eccdc85bccd221b
console.log("my-signature:", signature);
return signature;
}
async function callContractToDoSomething2(nonce) {
const signer = account.address;
const message1 = 778899n;
const message2 = 112233n;
const signature = await signAuth(message1, message2, nonce);
var encodedData;
try {
encodedData = encodeFunctionData({
abi: abiPermit2DoSomething,
functionName: "permit2DoSomething",
args: [signer, message1, message2, signature],
});
const hash = await walletClient.sendTransaction({
account: account,
to: CONTRACT_ADDRESS,
value: BigInt(0), // parseEther("0.0"),
data: encodedData,
});
console.log(`call contract , signer=${signer}, hash=${hash}`);
return hash;
} catch (e) {
console.log("call contract error:", e);
}
}
async function main() {
const nonce = 0; // the value of the nonce should be increased by 1 after each call
await callContractToDoSomething2(nonce);
}
await main();
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!