第7节:世界杯竞猜(链下签名)
小白入门:https://github.com/dukedaily/solidity-expert ,欢迎star转发,文末加V入群。
职场进阶: https://dukeweb3.com
概述
在区块链应用中,我们有很多需要使用链下签名的场景,例如:
- NFT白名单、空投等:管理员对每个白名单地址生成一个链下签名,mint或claim的时候,会传入这个链下签名,合约内部会对签名进行校验,从而完成白名单功能。
- permit功能:将approve和transferFrom合并为一笔交易,例如:uniswap的移除流动性功能。可以有效节约用户的gas费用。
多签钱包:多个owner进行签名,最后一个owner进行执行即可。
签名介绍
什么是签名呢? 我们在使用opensea的时候,经常会提示我们进行数字签名,如下图:
用户进行sign确认,就会用自己的私钥对一段数据进行签名,得到signature,这个signature是唯一的,它可以在不暴漏你私钥的情况下,证明你是私钥的持有者。任何人都可以证明signature的有效性。
以太坊使用椭圆曲线算法进行数字签名(ECDSA),签名后的数据有如下作用:
- 验证身份:验证私钥持有人
- 完整性:防止数据被篡改
- 不可否认:持有人无法否认签名
我们在区块链中发起的每一笔交易(转账、对合约写操作)都是使用私钥签名过的,矿工会在打包前对每笔交易进行校验。
其中,V,R,S是对签名分割后得到的数据,会在后面讲解,签名示例如下:
# private
# 这个私钥事暴露的,完全是测试使用的,千万不要往里面转钱!!!
0xc5e8f61d1ab959b397eecc0a37a6517b8e67a0e7cf1f4bce5591f3ed80199122
# address
0xc783df8a850f42e7F7e57013759C285caa701eB6
# message
['0xc783df8a850f42e7F7e57013759C285caa701eB6', 999]
# msgHash
0x416401c79c50b3b388890427985a289a2b8e6cd8e38949e79d5c77ec1ff88e88
# signature
0x381d3b66dbbbb2e83d054444197daa3b3309d19dcb5e81a8cc4015c4b13d8b7b79f1ce1f34b465b6cb211534869198dadf58118f0bf6208cd646d689b342af071c
签名验证过程
- 签名过程:ECDSA_正向算法(消息 + 私钥 + 随机数)= 签名
- 验证过程:ECDSA_反向算法(消息 + 签名)= 公钥
签名知识点总结:
- 使用私钥进行签名,使用公钥进行验证;
- 不对原文进行签名,而是对原文的hash进行签名。
ECDSA合约
在openzeppelin标准合约中,已经实现了对ECDSA标准合约,我们拆解一下,整个签名验证过程可以分为三个阶段(详见下图)
- 阶段一:打包原始消息,生成hash
- 阶段二:添加前缀,生成待签名的hash
- 阶段三:解析签名,获得解析的地址1
- 阶段四:校验地址1与实际签名的地址
阶段一:打包原始消息
在以太坊的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
阶段二:生成待签名数据
原始的消息
可以是能被执行的交易,也可以是其他任何形式。为了避免用户误签了恶意交易,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
阶段三:恢复地址
为了能够验证解析,我们需要先生成签名,有两种方式:方式1:调用metamask钱包生成;方式2:调用etherjs来生成
1. metamask生成签名
在metamask中导入私钥:0xc5e8f61d1ab959b397eecc0a37a6517b8e67a0e7cf1f4bce5591f3ed80199122,对应地址为:
0xc783df8a850f42e7F7e57013759C285caa701eB6
打开控制台F12(chrome)-> console,输入如下内容:
ethereum.enable() account = "0xc783df8a850f42e7F7e57013759C285caa701eB6" hash = "0xcfb170482914a76ca8521405f52699df67c7ebb8e3899f27cc8265ebdab98a36" ethereum.request({method: "personal_sign", params: [account, hash]})
点击Sign进行签名
签名成功后,得到签名:0x96065962a0fd61b56f2791d020de3ab8bec09fe452496988f2fcfcfa056737493289f791f74507e82caf4ebb34cea0211cf52d3d89585ecd02e6352c97dcf2691b
2. etherjs生成签名
在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); }) })
运行单元测试:npx hardhat test,可以得到相同的签名:
此时我们已经生成了签名,签名
是由数学算法生成的。这里我们使用的是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与签名数据对应的公钥(地址)
如果我们输入错误的signature或者签名数据,将会解析出错误的地址,即签名验证失败,如下图,我们将signature进行修改:将0x960改为0x760,效果如下,你会发现,解析出错误的地址。
阶段四:验证
接下来,我们只需要比对恢复的公钥
与签名者公钥_signer
是否相等:若相等,则签名有效;否则,签名无效:
function verify(bytes32 _msgHash, bytes memory _signature, address _signer) public pure returns (bool) {
return recoverSigner(_msgHash, _signature) == _signer;
}
效果如下,此为有效签名!
完整代码
// 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
链下签名实现白名单
核心逻辑为:
- 对将白名单用户地址,tokenId,进行签名入库;
- 用户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效果相同。
- Wallet的signMessage:自动添加前缀,并且做hash处理
- Hardhat的Signer的signMessage:调用以太坊的api:personal_sign,这个api内部会进行添加前缀,并进行hash处理。
- 签名标准
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 |
链接
- 在线签名:https://app.mycrypto.com/broadcast-transaction
- 签名总结:https://learnblockchain.cn/article/5012
- 数字签名:https://en.wikipedia.org/wiki/Digital_signature
- ERC191:https://eips.ethereum.org/EIPS/eip-191
加V入群:dukeweb3,公众号:阿杜在新加坡,一起抱团拥抱web3,下期见!
关于作者:国内第一批区块链布道者;2017年开始专注于区块链教育(btc, eth, fabric),目前base新加坡,专注海外defi,dex,元宇宙等业务方向。