第7节:世界杯竞猜(链下签名)

小白入门:https://github.com/dukedaily/solidity-expert ,欢迎star转发,文末加V入群。

职场进阶: https://dukeweb3.com

概述

在区块链应用中,我们有很多需要使用链下签名的场景,例如:

  1. NFT白名单、空投等:管理员对每个白名单地址生成一个链下签名,mint或claim的时候,会传入这个链下签名,合约内部会对签名进行校验,从而完成白名单功能。
  2. permit功能:将approve和transferFrom合并为一笔交易,例如:uniswap的移除流动性功能。可以有效节约用户的gas费用。
  3. 多签钱包:多个owner进行签名,最后一个owner进行执行即可。

  4. 点击查看视频

签名介绍

什么是签名呢? 我们在使用opensea的时候,经常会提示我们进行数字签名,如下图:

image-20221127163526321

用户进行sign确认,就会用自己的私钥对一段数据进行签名,得到signature,这个signature是唯一的,它可以在不暴漏你私钥的情况下,证明你是私钥的持有者。任何人都可以证明signature的有效性。

以太坊使用椭圆曲线算法进行数字签名(ECDSA),签名后的数据有如下作用:

  1. 验证身份:验证私钥持有人
  2. 完整性:防止数据被篡改
  3. 不可否认:持有人无法否认签名

我们在区块链中发起的每一笔交易(转账、对合约写操作)都是使用私钥签名过的,矿工会在打包前对每笔交易进行校验。

image-20221127165422619

其中,V,R,S是对签名分割后得到的数据,会在后面讲解,签名示例如下:

# private
# 这个私钥事暴露的,完全是测试使用的,千万不要往里面转钱!!!
0xc5e8f61d1ab959b397eecc0a37a6517b8e67a0e7cf1f4bce5591f3ed80199122

# address 
0xc783df8a850f42e7F7e57013759C285caa701eB6

# message
['0xc783df8a850f42e7F7e57013759C285caa701eB6', 999]

# msgHash
0x416401c79c50b3b388890427985a289a2b8e6cd8e38949e79d5c77ec1ff88e88

# signature
0x381d3b66dbbbb2e83d054444197daa3b3309d19dcb5e81a8cc4015c4b13d8b7b79f1ce1f34b465b6cb211534869198dadf58118f0bf6208cd646d689b342af071c

签名验证过程

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

image-20221127161350812

签名知识点总结:

  1. 使用私钥进行签名,使用公钥进行验证;
  2. 不对原文进行签名,而是对原文的hash进行签名。

ECDSA合约

在openzeppelin标准合约中,已经实现了对ECDSA标准合约,我们拆解一下,整个签名验证过程可以分为三个阶段(详见下图)

  1. 阶段一:打包原始消息,生成hash
  2. 阶段二:添加前缀,生成待签名的hash
  3. 阶段三:解析签名,获得解析的地址1
  4. 阶段四:校验地址1与实际签名的地址

image-20221127223259928

阶段一:打包原始消息

在以太坊的ECDSA标准中,被签名的消息为一组数据的hash值(由keccak256算法生成的byte32类型的数据),我们可以使用abi.encodePacked打包函数将任意多个参数进行打包,此处为:address和uint256类型。

    function getMessageHash(
        address _to,
        uint _amount
    ) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(_to, _amount));
    }

输入参数:0xc783df8a850f42e7f7e57013759c285caa701eb6, 100

输出:0xcfb170482914a76ca8521405f52699df67c7ebb8e3899f27cc8265ebdab98a36

image-20221127224627119

阶段二:生成待签名数据

原始的消息可以是能被执行的交易,也可以是其他任何形式。为了避免用户误签了恶意交易,EIP191提倡在消息前加上前缀prefix:"\x19Ethereum Signed Message:\n32"字符,并再做一次keccak256哈希,作为以太坊签名消息。经过getEthSignedMessageHash()函数处理后的消息,不能被用于执行交易。

    function getEthSignedMessageHash(bytes32 _messageHash)
        public
        pure
        returns (bytes32)
    {
        return
            keccak256(
                // 这是标准字符串: \x19Ethereum Signed Message:\n
                // 32表示后面的哈希内容长度
                abi.encodePacked("\x19Ethereum Signed Message:\n32", _messageHash)
            );
    }

输入参数:0xcfb170482914a76ca8521405f52699df67c7ebb8e3899f27cc8265ebdab98a36

输出:0x60a7e355f6d1a5885594e145ce67bd165a3e63337806f576b7b417d31cdb20da

image-20221127224750180

阶段三:恢复地址

为了能够验证解析,我们需要先生成签名,有两种方式:方式1:调用metamask钱包生成;方式2:调用etherjs来生成

1. metamask生成签名

  1. 在metamask中导入私钥:0xc5e8f61d1ab959b397eecc0a37a6517b8e67a0e7cf1f4bce5591f3ed80199122,对应地址为:0xc783df8a850f42e7F7e57013759C285caa701eB6

  2. 打开控制台F12(chrome)-> console,输入如下内容:

    ethereum.enable()
    account = "0xc783df8a850f42e7F7e57013759C285caa701eB6"
    hash = "0xcfb170482914a76ca8521405f52699df67c7ebb8e3899f27cc8265ebdab98a36"
    ethereum.request({method: "personal_sign", params: [account, hash]})
    
  3. 点击Sign进行签名

    image-20221128000459305

  4. 签名成功后,得到签名:0x96065962a0fd61b56f2791d020de3ab8bec09fe452496988f2fcfcfa056737493289f791f74507e82caf4ebb34cea0211cf52d3d89585ecd02e6352c97dcf2691bimage-20221128000525401

2. etherjs生成签名

  1. 在hardhat的test文件夹下创建sign.ts,内容如下:

    const { expect } = require("chai")
    const { ethers } = require("hardhat")
    
    describe("Signature", function () {
      it("signature", async function () {
        // 0xc783df8a850f42e7f7e57013759c285caa701eb6
        let privateKey = '0xc5e8f61d1ab959b397eecc0a37a6517b8e67a0e7cf1f4bce5591f3ed80199122'
        console.log('private:', privateKey);
    
        const signer = new ethers.Wallet(privateKey);
        console.log('address :', signer.address);
    
        const amount = 100
    
        let msgHash = ethers.utils.solidityKeccak256(
          ["address", "uint256"], [signer.address, amount]
        )
    
        console.log('msgHash:', msgHash);
        const sig = await signer.signMessage(ethers.utils.arrayify(msgHash))
    
        console.log('signature:', sig);
      })
    })
    
  2. 运行单元测试:npx hardhat test,可以得到相同的签名:image-20221128000610809

此时我们已经生成了签名,签名是由数学算法生成的。这里我们使用的是rsv签名签名中包含r, s, v三个值的信息。而后,我们可以通过r, s, v以太坊签名消息来求得公钥。下面的recoverSigner()函数实现了上述步骤,它利用以太坊签名消息 _ethSignedMessageHash签名 _signature恢复公钥(使用了内联汇编):

    function recoverSigner(bytes32 _ethSignedMessageHash, bytes memory _signature)
        public
        pure
        returns (address)
    {
        (bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);

          // 返回解析出来的签名地址,
        return ecrecover(_ethSignedMessageHash, v, r, s);
    }

        // 对私钥进行分割
    function splitSignature(bytes memory sig)
        public
        pure
        returns (
            bytes32 r,
            bytes32 s,
            uint8 v
        )
    {
          // 验证长度有效性
        require(sig.length == 65, "invalid signature length");

          // 通过读取内存数据,根据规则进行截取,返回r,s,v数据
        assembly {
            r := mload(add(sig, 32))
            s := mload(add(sig, 64))
            v := byte(0, mload(add(sig, 96)))
        }
    }

通过recoverSigner函数计算,我们恢复得到signature与签名数据对应的公钥(地址)

image-20221128001023810

如果我们输入错误的signature或者签名数据,将会解析出错误的地址,即签名验证失败,如下图,我们将signature进行修改:将0x960改为0x760,效果如下,你会发现,解析出错误的地址。

image-20221128001415565

阶段四:验证

接下来,我们只需要比对恢复的公钥与签名者公钥_signer是否相等:若相等,则签名有效;否则,签名无效:

    function verify(bytes32 _msgHash, bytes memory _signature, address _signer) public pure returns (bool) {
        return recoverSigner(_msgHash, _signature) == _signer;
    }

效果如下,此为有效签名!

image-20221128002314447

完整代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8;

contract VerifySignature {
    // 1. 对真正的内容进行哈希处理,私钥最终只对这个进行签名
    function getMessageHash(
        address _to,
        uint _amount
    ) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(_to, _amount));
    }

    // 2. 对内容的哈希进行二次哈希,这个用于做verify处理
    function getEthSignedMessageHash(bytes32 _messageHash)
        public
        pure
        returns (bytes32)
    {
        return
            keccak256(
                //这是标准字符串: \x19Ethereum Signed Message:\n
                //32表示后面的哈希内容长度
                abi.encodePacked("\x19Ethereum Signed Message:\n32", _messageHash)
            );
    }

    // 3. 传入基础数据和签名,内部会计算出哈希值,并使用签名进行校验。
    // 这个是最核心的方法,最终外部仅调用这个
    function verify(bytes32 _msgHash, bytes memory _signature, address _signer) public pure returns (bool) {
        return recoverSigner(_msgHash, _signature) == _signer;
    }

    function recoverSigner(bytes32 _ethSignedMessageHash, bytes memory _signature)
        public
        pure
        returns (address)
    {
        (bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);

        return ecrecover(_ethSignedMessageHash, v, r, s);
    }

    function splitSignature(bytes memory sig)
        public
        pure
        returns (
            bytes32 r,
            bytes32 s,
            uint8 v
        )
    {
        require(sig.length == 65, "invalid signature length");

        assembly {
            r := mload(add(sig, 32))
            s := mload(add(sig, 64))
            v := byte(0, mload(add(sig, 96)))
        }
    }
}

单元测试:

npx hardhat test test/verifySignature.ts

image-20221128004656106

链下签名实现白名单

核心逻辑为:

  1. 对将白名单用户地址,tokenId,进行签名入库;
  2. 用户mint时,传入签名,在mint中进行校验,只有校验为true的用户才可以mint,从而完成白名单功能。
    function mint(uint256 _tokenId, bytes memory _signature) external {
          // 将用户地址和_tokenId打包消息
        bytes32 _msgHash = getMessageHash(msg.sener, _tokenId); 

          // 计算以太坊签名消息
        bytes32 _ethSignedMessageHash = getEthSignedMessageHash(_msgHash);

          // ECDSA检验通过
        require(verify(_ethSignedMessageHash, _signature), "Invalid signature");

          // 地址没有mint过
        require(!mintedAddress[_account], "Already minted!"); 
        _mint(_account, _tokenId);
        mintedAddress[_account] = true;
    }

总结

本文我们详细介绍了以太坊ECDSA链下签名的原理,并用代码进行了演示,链下签名与前面介绍的merkleTree都可以实现空投&白名单功能,具体选用哪一个取决于我们的业务场景,链下签名更加经济,但是更依赖中心化服务,当用户的白名单时动态产生时,使用链下签名更好;

现在我们已经有了链下签名的铺垫,下一节我们将介绍如何基于链下签名,实现多签功能,类似于genesis 多签钱包一样,敬请期待!

其他

Wallet的signMessage和hardhat的Singer.signMessage效果相同。

  1. Wallet的signMessage:自动添加前缀,并且做hash处理
  2. Hardhat的Signer的signMessage:调用以太坊的api:personal_sign,这个api内部会进行添加前缀,并进行hash处理。
  3. 签名标准
stage details ethers.js
initial encode(Tx: T) = RLP_encode(Tx)
normal encode(message) = "\x19Ethereum Signed Message:\n" \ \ len(message)\ \ message signer.sigMessage(hash)
EIP-712 encode(domainSeparator, message) = "\x19\x01"\ \ domainSeparator\ \ hashStruct(message) signer._signTypeData()
EIP-2612 special case for EIP-712

链接

加V入群:dukeweb3,公众号:阿杜在新加坡,一起抱团拥抱web3,下期见!

关于作者:国内第一批区块链布道者;2017年开始专注于区块链教育(btc, eth, fabric),目前base新加坡,专注海外defi,dex,元宇宙等业务方向。