思维导图

我把以太坊签名分为对消息签名与对交易签名,这两种签名都是基于ECDSA算法与流程,本章就让我们来搞清楚两种签名具体的内容。

签名概述

签名的作用或目的?

  • 身份认证:证明你拥有地址的私钥;
  • 不可否认:确认你的确发布过该消息;
  • 完整性:确保信息没有被篡改;

具体见维基百科:wikipedia签名的性质

什么是签名?

当我刚开始接触签名这个名词时,我也很困惑,此处的签名和现实世界中合同的签名有什么不同?当我签署一份租房合同,当我们租期到时,如果对屋内的物品所有损坏,房东可凭借这份合同上的内容对我进行索赔(扣押金),如果我进行抵赖,说我没签署过这份合同,那么房东可去司法机构进行签名笔迹认证。以太坊中的签名也是如此,在租房合同中签名是笔迹,在以太坊中的签名就是一段数据,这段数据的作用和我签署租房合同的签名笔迹没有任何不同,节点们(矿工们、验证者们)可以凭借这段数据进行身份认证,即证明这些消息就是我签署的(因为只有我拥有私钥),同时,我想抵赖也是不可能的,因为这段数据具备不可否认性,第三方也不可能对消息进行篡改,因为这段数据具备完整性。如果对上面阐述的内容暂时不理解也没关系,继续往下看,多看一些资料很快就会理解的。此时我们只需记住,以太坊或者计算机中这个"签名"与现实世界中的向"合同上签名"是一个意义。下面先概括一下以太坊的签名与验证过程:

  • 签名过程:ECDSA_正向算法(消息 + 私钥 + 随机数)= 签名
  • 验证过程:ECDSA_反向算法(消息 + 签名)= 公钥

在以太坊、比特币中这个算法是经过二开的ECDSA(原始的ECDSA只有r、s组成,以太坊、比特币的ECDSA由r、s、v组成)。

使用ECDSA签名并验证

什么是ECDSA

ECDSA可理解为以太坊、比特币对消息、交易进行签名与验证的算法与流程。在智能合约层面,我们不必多关注其算法的细节,只需理解其流程,看得懂已有项目代码,可以在项目写出对应功能代码即可。

流程

  • 签名即正向算法(消息 + 私钥 + 随机数)= 签名,其中消息是公开的,私钥是隐私的,经过ECDSA正向算法可得到签名,即r、s、v(不用纠结与r、s、v到底什么,只需要知道这就是签名即可)。
  • 验证即反向算法(消息 + 签名)= 公钥,其中消息是公开的,签名是公开的,经过ECDSA反向算法可得到公钥,然后对比已公开的公钥。

签名交易

关键词

  • RLP:一种序列化的方式,其与网络传输中json的序列化/反序列化有一些不同,RLP不仅兼顾网络传输,其编码特性更确保了编码后的一致性,因为每笔交易过程中要进行Keccak256,如果不能保证编码后的一致性,会导致其Hash值不同,那么验证者就无法验证交易是否由同一个人发出。
    若对上面的阐述不理解,继续看下面的内容。
    编码方式详情见详解以太坊RLP编码(不用过度研究)。
  • Keccak256 :以太坊的Hash算法,生成32个字节Hash值。

签名交易流程

1. 构建原始交易对象

  • nonce: 记录发起交易的账户已执行交易总数。Nonce的值随着每个新交易的执行不断增加,这能让网络了解执行交易需要遵循的顺序,并且作为交易的重放保护。
  • gasPrice:该交易每单位gas的价格,Gas价格目前以Gwei为单位(即10^9wei),其范围是大于0.1Gwei,可进行灵活设置。
  • gasLimit:该交易支付的最高gas上限。该上限能确保在出现交易执行问题(比如陷入无限循环)之时,交易账户不会耗尽所有资金。一旦交易执行完毕,剩余所有gas会返还至交易账户。
  • to:该交易被送往的地址(调用的合约地址或转账对方的账户地址)。
  • value:交易发送的以太币总量。
  • data:
    • 若该交易是以太币交易,则data为空;
    • 若是部署合约,则data为合约的bytecode;
    • 若是合约调用,则需要从合约ABI中获取函数签名,并取函数签名hash值前4字节与所有参数的编码方式值进行拼接而成,具体参见文章Ethereum的合约ABI拓展
  • chainId:防止跨链重放攻击。 ->EIP155

2. 签署交易

签署交易可使用MetaMask和ethers库。

  • MetaMask
    前端:使用MetaMask进行签名为前端技术栈,目前比较流行为nextjs+ethers,我对前端不太了解,这里不做展开。
  • ethers库
    后端:使用ethers库可以进行交易的签名,详情见如下代码:
const ethers = require("ethers")
require("dotenv").config()

async function main() {
    // 将RPC与私钥存储在环境变量中
    // RPC节点连接,直接用alchemy即可
    let provider = new ethers.providers.JsonRpcProvider(process.env.RPC_URL)
    // 新建钱包对象
    let wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider)
    // 返回这个地址已经发送过多少次交易
    const nonce = await wallet.getTransactionCount()
    // 构造raw TX
    tx = {
      nonce: nonce,
      gasPrice: 100000000000,
      gasLimit: 1000000,
      to: null,
      value: 0,
      data: "",
      chainId: 1, //也可以自动获取chainId = provider.getNetwork()
    }
    // 签名,其中过程见下面详述
    let resp = await wallet.signTransaction(tx)
    console.log(resp)
    // 发送交易
    const sentTxResponse = await wallet.sendTransaction(tx);
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

wallet.signTransaction中发生了什么?

  1. 对(nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0)进行RLP编码;
  2. 对上面的RLP编码值进行Keccak256 ;
  3. 对上面的Keccak256值进行ECDSA私钥签名(即正向算法);
  4. 对上面的ECDSA私钥签名(v、r、s)结果与交易消息再次进行RPL编码,即RLP(nonce, gasPrice, gasLimit, to, value, data, v, r, s),可得到如下编码;
    0xf8511485174876e800830f42408080802da077cdb733c55b4b9b421c9e0e54264c6ccf097e5352ab117e346b69ac09755606a03ef912e80a022713cf5ccc3856f2d4159bf47de7c14090b66ef155b10d073776

    细心的同学可能会问,为什么步骤1中包含chainId字段,而步骤4中再次编码时没有chainId字段?原始消息内容都不一样,怎么可能会验证通过?先别急,这是因为chainId 是被编码到签名的 v参数中的,因此我们不会将chainId本身包含在最终的签名交易数据中(下面一小节也有阐述)。当然,我们也不会提供任何发送方地址,因为地址可以通过签名恢复。这就是以太坊网络内部用来验证交易的方式。

验证过程

交易签名发送后,以太坊节点如何进行身份认证、不可否认、完整性?

  1. 对上面最终的RPL解码,可得到(nonce, gasPrice, gasLimit, to, value, data, v, r, s);
  2. 对(nonce, gasPrice, gasLimit, to, value, data)和(v,r,s)ECDSA验证(即反向算法),得到签名者的address,细心的同学可以看到第一个括号少了chainId,这是因为chainId在ECDSA私钥签名(即正向算法) 时被编码到了v,所以由v可以直接解码出chainId(所以在对上面的RLP编码值进行Keccak256;这一步,肯定是把chainId复制了一下,给对上面的Keccak256值进行ECDSA私钥签名(即正向算法);这一步用);
  3. 对上面得到的签名者的address与签名者公钥推导的address进行比对,相等即完成身份认证、不可否认性、完整性

我们可以去MyCrypto - Ethereum Wallet Manager,将wallet.signTransaction生成的编码复制进去,对上述验证步骤有一个直观的感受,比对一下ECDSA反向算法得出的"from"是不是自己的地址?

安全问题

我们注意到原始交易对象里由Nonce和ChainID两个字段,这是为了防范双花攻击\重放攻击(双花与重放是相对的,本质都是重复使用一个签名,用自己的签名做对自己有利的重复叫双花,用别人的签名做对自己有利的重复叫重放):

  • Nonce:账户交易计数,以太坊的账户模型中记录着每个账户Nonce,即此账户发起过多少次交易。
  • ChainId:分叉链区分,比如我在以太坊链上给evil进行一笔转账交易,evil又可以去以太坊经典链上重放这笔交易,这时如果我在以太坊经典上也有资产,那么会遭受损失。所以EIP155提议加入ChainId,以防止跨链重放。以太坊ChainId为1,以太坊经典ChainId为61。

节点验证之后的动作

这里和签名的关系不大,便不再详述,大体可以看看下面这个帖子:
https://blog.csdn.net/LJFPHP/article/details/81261050

签名消息( =presigned message = 预签名)

大家看完上面的签名交易后,再看本章的签名消息,可能会有有些懵的感觉,会将其混为一谈,误以为这两个东西是平行的,各自发给节点的。这是对以太坊交易流程不清晰导致的,只需记住一点,发给节点的只能是交易签名+相应参数,其大概可分为三种情况:

  • 单纯的转账交易:就是上一章节的内容,由(nonce, gasPrice, gasLimit, to, value, data:空,chainId)经私钥通过ECDSA正向算法得到(v,r,s),将(nonce, gasPrice, gasLimit, to, value, data:空, v, r, s)发往节点。注意,这里data是空的。
  • 部署合约交易:由(nonce, gasPrice, gasLimit, to:空, value, data:合约创建字节码,chainId)经私钥通过ECDSA正向算法得到(v,r,s),将(nonce, gasPrice, gasLimit, to:空, value, data:合约创建字节码, v, r, s)发往节点。注意,to是空的, data是合约创建字节码,节点看到to是空的就知道这是部署合约交易。不明白什么是合约创建字节码,可以看看OpenZeppelin这个系列文章,我对这系类文章有相应笔记,待后续整理发出。。
  • 调用合约函数交易:由(nonce, gasPrice, gasLimit, to, value, data:selector+函数参数,chainId)经私钥通过ECDSA正向算法得到(v,r,s),将(nonce, gasPrice, gasLimit, to, value, data:selector+函数参数, v, r, s)发往节点。注意, data是selector+函数参数,例如bytes4(keccak256(bytes("foo(uint256,address,string,uint256[2])"))), x, addr, name, array。对于这个不明白的同样看上面OpenZeppelin文章即可。

综上,无论是部署合约交易还是调用合约函数交易都是改变data的值。 我们本章所讲的消息签名就是调用合约函数交易,调用对应合约函数(一般为验证verfiy函数),消息签名作为参数。

针对上面调用合约函数交易+消息签名的内容,我们配合流程图与层叠图加深理解:
流程图:

层叠图:

通用消息签名方法


当我在看EIP-191这个提案时(https://eips.ethereum.org/EIPS/eip-191),有些困惑,其标准格式到底是第一个红框还是第二个红框?经过看网上资料和琢磨一定时间后,其中的逻辑应该是这样的,我们可以把签名方法划分为三种:

  • 通用消息签名方法;
  • EIP-191标准签名方法;
  • EIP-712标准签名方法;

我们不用把他们想的太复杂,简单点来说通用签名方法就是添加了"\x19Ethereum Signed Message:\n"这个字符串的签名,如metamask的personal_sign方法和ECDSA库的toEthSignedMessageHash方法,添加这个字符串只是单纯的为了表明这是以太坊的签名,让我们看看通用签名方法的例子--NFT白名单。

metamask的personal_sign方法:

ECDSA库的toEthSignedMessageHash方法:

NFT白名单签名举例

让我们来写一个NFT白名单合约,想想如何实现白名单这个功能?

  • 把白名单地址存入数组,当目标地址调用mint函数时进行判断。 ->因为storage会耗费大量gas,项目方还没开始赚钱在deploy合约时就先破产了。后续白名单用户在Mint对白名单数组的读opcode又会耗费大量gas,白名单用户本来就想占点便宜,结果还花费了大量gas。所以这个方法几乎没有人用。
  • 用Merkle Tree来校验白名单,具体方法看0xAA师傅的这篇文章https://github.com/AmazingAng/WTF-Solidity/blob/main/36_MerkleTree/readme.md,这个方法的只需要storage一个root值,但相较于上面的方法已经非常节省gas。
  • 对白名单进行链下签名,这个方法是当下通用的,以我们目前NFT这个例子来说,也会需要storage一个owner,但相较于Merkle Tree在白名单用户mint的时候会少传一个数组,少很多opcode,也就为用户节省了gas。下面让我们来实操一下。

前提

技术栈:hardhat(社区hardhat-deploy包)+ethers.js,以下我只针对于白名单这个知识点进行讲解,不涉及技术栈的知识。
我们将accounts[0]作为deployer和signer,account[1]、account[2]、account[3]作为白名单地址。

合约

  • constructor,确定NFT名称、符号与signer,即签名者地址;
  • recoverSigner,对签名进行解析,返回地址。这里是白名单签名逻辑的核心,我们传入签名与签名时的参数,用ECDSA反向运算解析出签名者地址;
  • verify,确定signer就是签名者;
  • mintNft,调用verify,判断通过进行_safeMint;
    
    // SPDX-License-Identifier: UNLICENSED
    pragma solidity ^0.8.7;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

error SignatureNFT__VerifyFailed();

contract SignatureNFT is ERC721 {
// signer address
address public immutable i_signer;

constructor(address signer) ERC721("Test Signature NFT", "TSN") {
    // set signer address
    i_signer = signer;
}

// mint NFT
function mintNft(
    address account,
    uint256 tokenId,
    bytes memory signature
) public {
    if (!verify(account, tokenId, signature)) {
        revert SignatureNFT__VerifyFailed();
    }
    _safeMint(account, tokenId);
}

// Check if signer and i_signer are equal
function verify(
    address account,
    uint256 tokenId,
    bytes memory signature
) public view returns (bool) {
    address signer = recoverSigner(account, tokenId, signature);
    return signer == i_signer;
}

// Return signer address
function recoverSigner(
    address account,
    uint256 tokenId,
    bytes memory signature
) public pure returns (address) {
    // 后招
    bytes32 msgHash = keccak256(abi.encodePacked(account, tokenId));
    bytes32 msgEthHash = ECDSA.toEthSignedMessageHash(msgHash);
    address signer = ECDSA.recover(msgEthHash, signature);
    return signer;
}

}


#### 签名脚本
- 逻辑:hash(签名参数) -> signMessage(hash(签名参数));
- 生产环境中,我们将signers[index].address换为白名单地址,将signers[0]换位signer即可;

```javascript
const { ethers } = require("hardhat")

// 对要签名的参数进行编码
function getMessageBytes(account, tokenId) {
    // 对应solidity的Keccak256
    const messageHash = ethers.utils.solidityKeccak256(["address", "uint256"], [account, tokenId])
    console.log("Message Hash: ", messageHash)
    // 由于 ethers 库的要求,需要先对哈希值数组化
    const messageBytes = ethers.utils.arrayify(messageHash)
    console.log("messageBytes: ", messageBytes)
    // 返回数组化的hash
    return messageBytes
}

// 返回签名
async function getSignature(signer, account, tokenId) {
    const messageBytes = getMessageBytes(account, tokenId)
    // 对数组化hash进行签名,自动添加"\x19Ethereum Signed Message:\n32"并进行签名
    const signature = await signer.signMessage(messageBytes)
    console.log("Signature: ", signature)
}

async function main() {
    signers = await ethers.getSigners()
    // 我们将accounts[0]作为deployer和signer,account[1]、account[2]、account[3]作为白名单地址
    for (let index = 1; index < 4; index++) {
        await getSignature(signers[0], signers[index].address, index)
    }
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

部署脚本

在部署脚本中,我们直接用deployer作为constructor参数传入,即用deployer当作signer;

const { network, ethers } = require("hardhat")

module.exports = async ({ getNamedAccounts, deployments }) => {
    const { deployer } = await getNamedAccounts()
    console.log(deployer)
    const { deploy, log } = deployments
    const chainId = network.config.chainId

    const args = [deployer]

    await deploy("SignatureNFT", {
        contract: "SignatureNFT",
        from: deployer,
        log: true,
        args: args,
        waitConfirmations: network.config.waitConfirmations || 1,
    })
    log("[==>]------Deployed!------------------------------------------")
}

module.exports.tags = ["SignatureNFT"]

我们就选取最后一个Signature,即项目方给accounts[3]的签名。

测试脚本

分别对每个函数进行测试,其中mintNft为模拟交易
注意:
当我对mintNft函数进行错误性测试时(即第二个it,"incorrect mintNft shoule trigger error of SignatureNFTVerifyFailed"),我开始写的是.to.be.revertedWith("SignatureNFTVerifyFailed"),但出现了如下报错,于是我肯定ECDSA.recover这个函数中有先行报错,但我把其中四个revert报错都试过来了,还是会有but it reverted with a custom error这个提示,大家有兴趣可以找找这个先行报错在哪里,我就不找了,直接用.to.be.revertedWithCustomError()吧...


const { getNamedAccounts, ethers, network } = require("hardhat")
const { assert, expect } = require("chai")

describe("SignatureNFT", function () {
    let signatureNFT, deployer

    beforeEach(async function () {
        // 我们将accounts[0]作为deployer和signer,account[1]、account[2]、account[3]作为白名单地址
        accounts = await ethers.getSigners()
        console.log(`accounts[0]:${accounts[0].address}`)
        console.log(`accounts[1]:${accounts[1].address}`)
        // 通过hardhat.config.js配置deployer就是accounts[0]
        // 当然直接用accounts[0]也行,这里显得直观些
        deployer = (await getNamedAccounts()).deployer
        console.log(`deployer:${deployer}`)
        // 部署合约
        await deployments.fixture(["SignatureNFT"])
        // 获得合约对象,若getContract没有account传入,则为deployer连接
        signatureNFT = await ethers.getContract("SignatureNFT")
    })

    // test constructor
    describe("constructor", function () {
        it("i_signer is deploy_address when deploy constract", async function () {
            const signer = await signatureNFT.i_signer()
            assert.equal(signer, deployer)
        })
    })

    //test recoverSigner
    describe("recoverSigner", function () {
        it("recoverSigner could return address of account[0](deployer) when contract deploy in default chain", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            const tokenId = 3
            const account = accounts[3]
            const signer = await signatureNFT.recoverSigner(account.address, tokenId, signature)
            assert.equal(signer, deployer)
        })
    })

    //test varify
    describe("varify", function () {
        it("function varify should return ture", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            const tokenId = 3
            const account = accounts[3]
            const verify = await signatureNFT.verify(account.address, tokenId, signature)
            assert.equal(verify, true)
        })
    })

    // correct mintNft
    describe("mintNft", function () {
        it("the third tokenId should belong to account[3] when mint the third signature", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            const tokenId = 3
            const account = accounts[3]
            const verify = await signatureNFT.mintNft(account.address, tokenId, signature)
            const owner = await signatureNFT.ownerOf(3)
            assert.equal(owner, account.address)
        })

        // incorrect mintNft shoule trigger error of SignatureNFT__VerifyFailed
        it("incorrect mintNft shoule trigger error of SignatureNFT__VerifyFailed", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            // 我们规定accounts[3]只能Mint tokenId_3,他想Mint tokenId_2,那肯定不可以。
            const tokenId = 2
            const account = accounts[3]
            // 这里有个注意点
            await expect(
                signatureNFT.mintNft(account.address, tokenId, signature)
            ).to.be.revertedWithCustomError(signatureNFT, "SignatureNFT__VerifyFailed")
        })
    })
})

综上

通用签名方法实现逻辑为:

  • 签名逻辑:parameter... -> keccak256得到"消息Hash" -> 以太坊签名消息Hash -> 加入私钥进行运算得到"签名”。
    • 消息HASH -> keccak256(abi.encodePacked(parameter...) 。
    • 以太坊签名消息 -> keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)) 。
  • 合约验证逻辑:ecrecover(_msgHash, v, r, s) 可以"消息"和"签名"中的vrs推出地址,然后我们在我们需要的程序中对其进行对比即可。

EIP-191

我们再一次看看这张图,第一个红框就是EIP-191的实现格式,以0x1900,0x1945开头的数据段可视为EIP191,它有什么作用?在https://eips.ethereum.org/EIPS/eip-191的Motivation中可以看出,EIP-191提出了在签名中加入合约自身的address参数,以防止重放攻击的手法。
我们这里不多阐述EIP-191,因为在生产中,人们似乎用更高端的EIP-712更多,EIP-712相当于继承了EIP-191,而且签名时还能小狐狸上显示签名内容,既高端又安全。那么这里我们就略过EIP-191,直接进行EIP-712,对EIP-191有兴趣的同学可以去看看https://eips.ethereum.org/EIPS/eip-191 最下方的例子。

把Version byte写为0x01就是EIP-712啦。

关于重放攻击

例子Motivation中用多签钱包举例,实际上任何两个实现逻辑相同的合约,都会存在重放攻击的风险,如我们举例NFT白名单,试想项目方想布置另一套NFT,只是修改了URI就进行部署了,那么毫无疑问上一个项目的白名单撸穿。
这是一个油管视频截图(https://www.youtube.com/watch?v=jq1b-ZDRVDc, 再看看这个https://solidity-by-example.org/hacks/signature-replay/) ,很好的解释了如何防范重放攻击:

  • 没有任何防护的多签钱包,Eve重放Signature就会再次取出eth
    • 在encodePacked的时候加入nonce参数,这就相当于每个Signature具备唯一性;
    • 在合约逻辑上加入一个mapping,当一个Signature用过后就将其设置为true,表示已用过;
  • 如果这个多签钱包的代码被被另一个项目方复用,正巧我在Eve和Alice也想在此放置资产,怎么防止重放?
    • 在encodePacked的时候加入合约的address参数,这就相当于每个Signature对每个地址具备唯一性;
  • 一个合约工厂用create2在可预测地址创建多签钱包,并且带有selfdestruct(),管理员没事还运行selfdestruct(),如何防范重放?(哪个奇葩会写出这样的合约)
    • 防不了了,Alice在A地址的多签钱包存了10ETH,然后给Eve一个Signature,同意其取5个ETH,Eve正常取了5个之后想要重放攻击,因为有nonce并且Signature被记录,所以重放不了,但合约的owner在某种情况下selfdestruct()了合约,这是nonce恢复初始值,但Alice没注意到selfdestruct()事件,仍然向A地址的多签钱包存钱,这时Eve就可以重放Signature了,再拿走5ETH。

这里我再加一个:

  • 加入chainId,防范在不同链的、逻辑相同的合约的重放攻击

EIP-712

在学习EIP-712时,不妨让我们先看一下其具体实现。以代码下是UniswapV2ERC20合约的实现,在20行bytes32 digest由三部分组成:

  • '\x19\x01' ,固定字符串;
  • DOMAIN_SEPARATOR,由constructor中定义;
  • keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)),其中PERMIT_TYPEHASH是constant变量

而EIP-712就是由以上三部分组成。

string public constant name = 'Uniswap V2';
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
constructor() public {
    uint chainId;
    assembly {
        chainId := chainid
    }
    DOMAIN_SEPARATOR = keccak256(
        abi.encode(
            keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
            keccak256(bytes(name)),
            keccak256(bytes('1')),
            chainId,
            address(this)
        )
    );
}
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
    require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
    bytes32 digest = keccak256(
        abi.encodePacked(
            '\x19\x01',
            DOMAIN_SEPARATOR,
            keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
        )
    );
    address recoveredAddress = ecrecover(digest, v, r, s);
    require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
    _approve(owner, spender, value);
}

"面子"上的区别

在https://eips.ethereum.org/EIPS/eip-712 中,我们可以看出,EIP-712与通用签名消息方法、EIP-191的"面子"上的区别为在钱包签名时显示的不同,遵循EIP-712方法可使签名内容可视化,这会提高我们签名时的安全性,试想,你的主机被黑客控制,准备签名的Message被替代,凭借肉眼,你肯定看不出签名的内容有何不同。

"里子"上的区别

EIP-712 结构式

encode(domainSeparator,message)=x19x01∣∣domainSeparator∣∣hashStruct(message)

EIP-712 结构解析

对比我们上述"通用消息签名方法"中只是对要签名的参数进行序列化、keccak256、添加"\x19Ethereum Signed Message:\n32"后再次序列化与keccak256、签名相比,EIP-712是有着结构化上的要求的,我这里针对其结构式进行一个思维导图上的解析,大家如果想看文字上的描述,可以研读如下资料:

下面让我们对照UniswapV2ERC20和思维导图看看domainSeparator和hashStruct(meaasge)具体实现。

签名域-domainSeparator

domainSeparator由两部分组成,第一部分为对结构体的keccak256(encodeType),第二部分为结构体的具体实现(encodeData);
domainSeparator结构体如下所示,一般来说salt(随机数)会省略;

struct EIP712Domain{
    string name, //用户可读的域名,如DAPP的名字
    string version, // 目前签名的域的版本号
    uint256 chainId, // EIP-155中定义的chain ID, 如以太坊主网为1
    address verifyingContract, // 用于验证签名的合约地址
    bytes32 salt // 随机数
}
  • 再让我们对照UniswapV2ERC20来记几个结论:
    • encodeType与encodeData都要按照如上的结构体顺序来实现,其中字段可以省略,但不可以颠倒顺序,如UniswapV2ERC20所示,省略了salt字段,但是按照name、version、chainId、verifyingContract来排序的;
    • 针对string 或者 bytes 等动态类型,即长度不定的类型,其取值为 keccak256(string) 、 keccak256(bytes) 即内容的hash值;
    • 我们可以看到这里用的是abi.encode而非abi.encodePacked,这是因为domainSeparator结构体要求每个字段占据256位,以便于前端分割。
      DOMAIN_SEPARATOR = keccak256(
        abi.encode(
            // encodeType
            keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
            // encodeData
            keccak256(bytes(name)),
            keccak256(bytes('1')),
            chainId,
            address(this)
        )
      );

签名对象-hashStruct(message)

  • 一般来讲,hashStruct(message)与domainSeparator格式相同,也是由由两部分组成,第一部分为对自定义结构体的keccak256(encodeType),第二部分为自定义结构体的具体实现(encodeData);
  • 由注释可知,PERMIT_TYPEHASH就是Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)的hash,注意自定义对象名要首字母大写;
  • encodeData与encodeType顺序要相同;
    // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
    bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
    bytes32 digest = keccak256(
          abi.encodePacked(
              '\x19\x01',
              DOMAIN_SEPARATOR,
              keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
          )
      );

    然后就是熟悉的套路,ecrecover出address,进行比较,最后授权。

    
    address recoveredAddress = ecrecover(digest, v, r, s);
      require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
      _approve(owner, spender, value);
### 签名脚本
用uniswapV2结构体进行举例,注意我们要先确定verifyingContract,即pair的地址。
```javascript
const { ethers } = require("hardhat")

async function signTypedData(signer) {
    const domain = {
        name: "Uniswap V2",
        version: "1",
        chainId: 1,
        verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC",
    }

    // The named list of all type definitions
    const types = {
        Permit: [
            { name: "owner", type: "address" },
            { name: "spender", type: "address" },
            { name: "value", type: "uint256" },
            { name: "nonce", type: "uint256" },
            { name: "deadline", type: "uint256" },
        ],
    }

    // The data to sign
    const value = {
        owner: xx,
        spender: xx,
        value: xx,
        nonce: xx,
        deadline: xx,
    }

    const signature = await signer._signTypedData(domain, types, value)
    return signature
}

async function main() {
    signers = await ethers.getSigners()
    signature = await signTypedData(signers[0])
    console.log(signature)
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

uniswap为什么要应用EIP712?

实质上还是和NFT白名单的目的一样——为了节省GAS,uniswapV2分为pair合约UniswapV2Pair.sol和路由合约UniswapV2Router02.sol。当我们要移除流动性时需要通过路由合约来操作pair合约,试想如果没有应用消息签名,其操作路径为先在pair合约进行授权,然后在路由合约中移除流动性,也就是两笔交易。而应用消息签名只需要一笔交易即可完成。可想而知,面对Uniswap这种海量交易的情况下,也节省海量的GAS,也节省了海量的链上资源。

后记:

  • 以上为签名相关的知识点讲解,内容肯定有疏忽或者理解不到位的情况,欢迎各位大佬交流指正。
  • 封面出自Jose Aguinaga文章,如涉及侵权,请站内私信,我会及时整改。
  • 如需转载本文,请注明出处。
  • ethsamgs 2022.11.8

参考&致谢

转载:https://learnblockchain.cn/article/5012