Skip to content

EIP712实践

zhtkeepup edited this page Jun 16, 2024 · 5 revisions

EIP712实践

EIP712介绍

  在数字签名的场景中,签名者将信息用私钥加密,然后公布公钥;验证者使用公钥将加密后的信息解密,并与原始信息比对(一般签名对象为原始消息的[散列值])。在这个流程里,当用户用web3钱包对消息进行签名时,普通用户通常无法看到有意义的结构化数据,而可能是一串无法识别的十六进制字符串(增加用户被诈骗的风险),如下图红色椭圆所示区域:
image
  
  在EIP712规范中,提供了一种对(用户可见的)类型结构化数据进行签名的方案,当用户对消息进行签名时,DAPP应该将有意义的类型结构化数据展示给用户查看,如下图红色椭圆所示区域:
image
  
  在EIP712之前,参与签名的输入信息包含交易和字节串。而在EIP712里,参与签名的输入信息包括三部分:交易𝕋、字节串𝔹⁸ⁿ、结构化数据𝕊。实现EIP712的DAPP,通常应该将类型结构化数据展示给用户。

通过Solidity合约代码理解EIP712

  EIP712的典型应用流程概括如下图:
image
(图中左侧对应拥有私钥的用户,右侧对应智能合约。用户使用私钥对消息签名,智能合约收到签名消息后,从签名消息中恢复出地址,并对地址进行验证"是否为合法用户")

备注

  下文所列为部分代码片段。openzeppelin已经实现了EIP712规范,因此我们的样例代码是直接依赖openzeppelin实现的,完整的参考代码位于:https://github.com/zhtkeepup/eip712practice

持有私钥的用户端模拟代码(solidiy)

  以下是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);
    }
}

  

类型结构化数据的定义与哈希过程中的关键点:

  1. 测试代码中变量digest的值应该与验证合约中变量hash的值一样,即digest@test_permitDoSomething == hash@eip712permit;
  2. 由前面第一点可知,在我们的实际应用中,用户端用于生成digest的输入数据及其格式,应该与合约代码中用于生成hash时指定的数据及其格式完全一致;
  3. 同时,用于生成digest(或hash)而指定的数据格式,应该完全符合EIP712的规范.

针对前面所列三点,以及openzepplin工具库,实际开发过程中我们要理解或注意的点有:

  1. 合约代码中domainSeparator()对应EIP712规范的"Definition of domainSeparator"部分,其结果依赖:SIGNING_DOMAIN_NAMESIGNING_DOMAIN_VERSION、当前区块链的链ID、当前合约的地址;
  2. 而最终的哈希结果直接依赖:domainSeparator()PERMIT_TYPEHASH、业务参数值;
  3. 从合约代码看,PERMIT_TYPEHASH与对应的业务参数值,并无实际关联,只是人眼看起来有联系而已。但事实上,这个“人眼看起来的联系”是属于EIP712规范的一部分;
  4. 第三点所述的“人眼看起来的联系”,在我们目前这个场景中,若随意修改PERMIT_TYPEHASH的值也是正常的,因为我们的测试代码中生成签名的部分与验证代码使用了相同的函数,但是,当用户签名的部分由其他代码(如js)实现时,而js中若我们通常调用第三方工具库,因此我们必须严格依照EIP712的规范来实现,请参考下一章节“在js里使用viem实现EIP712中的用户签名”。   

在js里使用viem实现EIP712中的用户签名

  以下是javascript里使用viem实现EIP712中的用户签名的相关代码:

  • 注意1:代码中domain 里的name、version必须与合约中SIGNING_DOMAIN_NAME、SIGNING_DOMAIN_VERSION保持一致,同时chainId与verifyingContract必须与实际的合约信息一致;
  • 注意2:在我们的合约代码中,PERMIT_TYPEHASH内的值,每个逗号两端都不能有空格,同时PERMIT_TYPEHASH应当与当前js代码里的types 保持一致,语义上保持一致。
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();