消息签名与Permit
ECR20 标准代币有一个 approve 函数,它可以将当前用户指定的币数授权给另一个账户操作,该函数定义如下:
1 2 |
function approve(address spender, uint256 amount) public returns (bool) |
调用 approve 函数必须是这个账户的 Owner 才可以执行操作,有没有一种办法像比特币那种使用账户的私钥离线签名,验证签名后就能执行操作,不需要直接使用 Owner 联网操作? permit 可以做到,这个函数是 ERC20 的扩展,定义如下:
1 2 |
function permit(address owner,address spender,uint256 value,uint256 deadline,uint8 v,bytes32 r,bytes32 s) |
在学习 permit 原理之前,有必要先了解 Solidity 如何打包与哈希运算、如何离线签名、如何验签等一些知识点。
打包与哈希运算
Solidity 一般是调用 keccak256 函数计算哈希。我们做一个测试,定义一个 getHash 的函数,接受一个字符串,将字符串使用 encodePacked 打包,然后再调 keccak256 即可获取字符串的哈希,代码如下:
1 2 3 4 |
function getHash(string memory message) external pure returns(bytes32){ return keccak256(abi.encodePacked(message)); } |
encodePacked 是一个很有用的函数,它能够将任一数据,打包成十六进制数据,方便做进一步处理,比如将三个不同类型的变量数据加包在一起,代码如下:
1 2 3 4 |
function encodePacked(string memory message, address addr, uint num) external pure returns (bytes memory){ return abi.encodePacked(message, addr, num); } |
执行之后,在 getHash 函数输入参数字符串 123,会看到返回的 Hash 值,在 encodePacked 函数里输入三个参数,分别是字符串 123、地址 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4,数字 1,会看到打包后的结果,如下图所示:
消息签名与验签
编写一个合约用于验签,名称为 VerifySign,有 5 个函数,第一个函数名称是 getMessageHash,功能接受一个字符串参数获取哈希,在上面我们讲解了哈希的获取方法。第二个函数名称是 getEthSignedMessageHash,传入的参数是第一个函数获取到的哈希,这个函数的功能相当于获取了两次哈希。第三个函数名称是 getSigner,功能是获取签名者,返回签名者的地址,对比这个地址是否匹配即可验签。第四个函数名称是 splitSign,用于拆分签名数据,将签名数据拆分成 r,s,v 三个数据,这三个数据可以了解一下椭圆加密。第五个函数名称是 verify,就是判断签名者的地址是否与我们提供的地址一样,一样则代表验签成功,否则验签失败。具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
pragma solidity ^0.8.0; contract VerifySign { function getMessageHash(string memory _message) public pure returns (bytes32){ return keccak256(abi.encodePacked(_message)); } function getEthSignedMessageHash(bytes32 _messageHash) public pure returns (bytes32){ return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", _messageHash)); } function getSigner(bytes32 _ethSignedMessageHash, bytes memory _sign) public pure returns(address){ (bytes32 r, bytes32 s, uint8 v) = splitSign(_sign); return ecrecover(_ethSignedMessageHash, v, r, s); //返回签名者地址 } function splitSign(bytes memory _sign) public pure returns (bytes32 r, bytes32 s, uint8 v){ assembly { r := mload(add(_sign, 32)) s := mload(add(_sign, 64)) v := byte(0, mload(add(_sign, 96))) } } function verify(address _signer, string memory _message, bytes memory _sign) external pure returns (bool){ bytes32 messageHash = getMessageHash(_message); bytes32 ethSignedMessageHash = getEthSignedMessageHash(messageHash); address signer = getSigner(ethSignedMessageHash, _sign); if(signer == _signer){ return true; } return false; } } |
验签的合约写好了,如何签名呢?在 geth 客户端里提供了签名的方法,加载 geth,先查看当前的账户,如果没有账户则新建
1 2 3 4 5 6 7 8 9 10 |
> eth.accounts [] > personal.newAccount(); Passphrase: Repeat passphrase: "0x1f0dd411dca792d3cc502515a325755315365b52" > eth.accounts ["0x1f0dd411dca792d3cc502515a325755315365b52"] |
新建好账户后,需要先输入密码解锁账户
1 2 3 4 5 |
personal.unlockAccount(eth.accounts[0]) Unlock account 0x1f0dd411dca792d3cc502515a325755315365b52 Passphrase: true |
接下来布署合约,输入字符串 hello,记录返回的哈希数据
在 geth 客户端调用 web3 接口,指定 0x1f0dd411dca792d3cc502515a325755315365b52 账户给 hello 的哈希数据签名,返回结果如下:
1 2 3 4 5 |
> web3.personal.sign("0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8", "0x1f0dd411dca792d3cc502515a325755315365b52") Give password for account 0x1f0dd411dca792d3cc502515a325755315365b52 Password: "0x252561ffdd60f69b7b62b136dbb588c32af5b131804b22814da0c1acd78f4a655d206bfb1e8632d8e792ddc360ba1d7edb23e2e446cc24a7661b7c1d5a38ef9c1b" |
将 geth 客户端返回的签名数据放入 getSigner 的参数 _sign,_ethSignedMessageHash 参数是对 messageHash 的第二参哈希,返回的结果是 0x1F0dD411dca792d3CC502515a325755315365B52,与 geth 客户端返的签名账户一致,说明验签成功。
![image-20220803164949764](/Users/geek/Library/Application Support/typora-user-images/image-20220803164949764.png)
也可以在 verify 方法里验证,提供相应的参数,返回 true 代表验签成功,如下图所示:
![image-20220803172312358](/Users/geek/Library/Application Support/typora-user-images/image-20220803172312358.png)
Permit
下面我们来看一下 permit 函数的实现,该函数所有文件是 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/draft-ERC20Permit.sol,有 6 个参数,第一个参数 owner 是被授权的地址,第二个参数 spender 是授权给谁,第三个参数 value 是授权币的数量,第四个参数 deadline 是一个时间戳,超过这个时间就无效,最后三个参数是拆分后的签名数据。其中 recover 这个函数是用于验签,返回 signer,将 signer 与 owner 做对比,一致则代表验签成功,不一致则验签失败,具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public virtual override { require(block.timestamp <= deadline, "ERC20Permit: expired deadline"); //判断时间 bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)); bytes32 hash = _hashTypedDataV4(structHash); address signer = ECDSA.recover(hash, v, r, s); //验签 require(signer == owner, "ERC20Permit: invalid signature"); _approve(owner, spender, value); } |
我们需要找一个币的合约代码来做测试,用这个币做测试,https://etherscan.io/address/0xD417144312DbF50465b1C641d016962017Ef6240,为了方便测试效果,我们把 permit 函数修改一下,添加一个 permit 只要验签成功则调用 _approve 授权,修改后的代码如下:
1 2 3 4 5 6 7 |
function permit2(address owner, address spender, uint256 amount, bytes32 _hash, uint8 v, bytes32 r, bytes32 s) public returns(address){ address signer = ecrecover(_hash, v, r, s); require(signer != address(0) && signer == owner, "CovalentPermit: Invalid signature"); _approve(owner, spender, amount); } |
我们一直使用的是 remix 做测试,remix 上默认的账户如何导入到本地使用呢?需要找到它的私钥,通过了解发现 remix 的私钥是写死代码里的,可以查找 remix 的源码找到私钥,下面我们使用 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 这个地址来做测试。
1 2 3 4 5 6 7 |
Private key Address 503f38a9c967ed597e47fe25643985f032b072db8075426a92110f82df48dfcb 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 7e5bfb82febc4c2c8529167104271ceec190eafdca277314912eaabdb67c6e5f 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2 cc6d63f85de8fef05446ebdd3c537c72152d0fc437fd7aa62b3019b79bd1fdd4 0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db 638b5c6c8c5903b15f0d3bf5d3f175c64e6e98a10bdb9768a2003bf773dcb86a 0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB ...... |
在 geth 客户端使用 importRawKey 导入私钥,设置账户密码 123,然后再使用该账户签名 hello 这个字符串
1 2 3 4 5 6 |
>personal.importRawKey("503f38a9c967ed597e47fe25643985f032b072db8075426a92110f82df48dfcb", "123"); >web3.personal.sign("0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8", "0x5b38da6a701c568545dcfcb03fcb875f56beddc4") Give password for account 0x5b38da6a701c568545dcfcb03fcb875f56beddc4 Password: "0x8f5786dc8f1ec71f2f9c1a1c49fb1fc7db248c92890ef4521fb07bf1bcc745644c66d9b7e5f420767931b830d1b41d9d36d0ee9daa7b6dec07e2d39a32ad0c5a1b" |
需要将生成的签名数据进行拆分,还记得在上面的验签合约里的 splitSign 这个函数吗?它可以将签名拆分成 r, s, v 三个数据。
最后将相应的数据填写上,执行 permit2 无论是否使用 Owner 都可以执行这一笔授权操作,如下图所示:
为了确认授权是否成功,可以调用 ECR20 的 allowance 查看,返回的结果是 10,说明 permit2 是成功的。
转载请注明:exchen's blog » 消息签名与Permit