Solidity示例合约ReceiverPays.sol学习
Posted Zero_Nothing
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Solidity示例合约ReceiverPays.sol学习相关的知识,希望对你有一定的参考价值。
一、前言
最近闲暇起来,打算把Solidity官方文档再看一遍,温故而知新!在看到示例合约微支付通道Micropayment Channel 时,决定动手去亲自实践一次(以前看到这只是看了源码,未真正部署测试)。没有想到,看上去挺简单的ReceiverPays合约却在一个地方卡了很久。以此文章记录这次ReceiverPays合约学习测试的过程,能给读者稍微提供一点点参考就足够了。
本文以Solidity v0.8.0文档为阅读版本。
Tips:
Micropayment Channel 最终完成合约为SimplePaymentChannel,它是在ReceiverPays合约上修改而来,因此搞明白ReceiverPays就已经足够了。
二、合约源码
什么也不说,先直接复制合约源码:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract ReceiverPays {
address owner = msg.sender;
mapping(uint256 => bool) usedNonces;
constructor() payable {}
function claimPayment(uint256 amount, uint256 nonce, bytes memory signature) public {
require(!usedNonces[nonce]);
usedNonces[nonce] = true;
// this recreates the message that was signed on the client
bytes32 message = prefixed(keccak256(abi.encodePacked(msg.sender, amount, nonce, this)));
require(recoverSigner(message, signature) == owner);
payable(msg.sender).transfer(amount);
}
/// destroy the contract and reclaim the leftover funds.
function shutdown() public {
require(msg.sender == owner);
selfdestruct(payable(msg.sender));
}
/// signature methods.
function splitSignature(bytes memory sig)
internal
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
require(sig.length == 65);
assembly {
// first 32 bytes, after the length prefix.
r := mload(add(sig, 32))
// second 32 bytes.
s := mload(add(sig, 64))
// final byte (first byte of the next 32 bytes).
v := byte(0, mload(add(sig, 96)))
}
return (v, r, s);
}
function recoverSigner(bytes32 message, bytes memory sig)
internal
pure
returns (address)
{
(uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);
return ecrecover(message, v, r, s);
}
/// builds a prefixed hash to mimic the behavior of eth_sign.
function prefixed(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\\x19Ethereum Signed Message:\\n32", hash));
}
}
源码是怎么设计的在官方文档上有,例如签名的参数包含接收者地址,防重放的nonce,支付的数量amount等。将这些参数压缩后计算一个哈希值作为一个消息,使用支付者的私钥签名该消息。接收者拿到此签名后就可以调用合约的claimPayment
函数来获取支付的资金。
三、函数主要功能介绍
3.1 claimPayment 函数
接收者使用该函数接收资金。
在claimPayment
函数中,会对签名进行验证,验证的方法是在本地构造一个相同的拟签名的消息,利用这个消息和签名得到签名者的账号(地址)。比如这个地址是不是支付者地址就行了。
如果claimPayment
函数中提供的参数和签名消息中参数不一致,最后一步验证签名者地址那就通不过,得到的不是支付者的地址(是另外一个随机地址)。
注意:这里是Solidity通过算法计算出的地址,而不是计算出私钥,这点大可放心 。
3.2 shutdown 函数
关闭合约(自杀),将合约中所有的资金发送到owner。
注意:这里有一个细节:
在0.5.0版本时,增加了address payable 。而 msg.sender 默认为payable的,所以我们平常可以直接写msg.sender.transfer()
而不需要先转换为payable
类型的。
但是从0.8.0版本开始,tx.origin与msg.sender不再默认为payable类型了。见
https://docs.soliditylang.org/en/v0.8.0/080-breaking-changes.html?highlight=payable#new-restrictions
所以这里selfdestruct
(自杀)函数里做了一次payable
转换。
3.3 splitSignature函数
分离签名中的r,s,v
其实签名就是r + s + v的16进制字符串形式。其中r与s都是32字节长度,v为1字节。所以长度为65。文档中已经解释了,由于在Solidity中操作bytes(或者字符串)不是很方便,使用内嵌汇编来进行这个操作。
这里稍微要提的是bytes(不是bytes32)这种引用/动态类型(无固定大小)在内存中的索引方法。
不同于值变量(固定大小),内存中直接保存的是该值的大小(不会超过256位,32字节);动态类型在内存中保存的是该变量的内存址,然后接下来一个字节(256位)是该bytes的长度(长度前缀),再接下来的字节才是正式的内容。
了解到这个,我们再看这个函数中的内嵌汇编就很简单了。
assembly {
// first 32 bytes, after the length prefix.
r := mload(add(sig, 32))
// second 32 bytes.
s := mload(add(sig, 64))
// final byte (first byte of the next 32 bytes).
v := byte(0, mload(add(sig, 96)))
}
EVM中虚拟机中所有存储都是以一个word(256位,32字节)为单位的。
这里sig是一个内存地址,再加一个字节(长度为32)就是r
的地址了。所以使用了一个mload来读取一个字节的内容(就是r的内容)。
同样,再过32字节就是s
的内容。
最后的v
稍有不同,它读取了整个word内容(mload函数),然而却使用byte
函数取其最开始字节的内容(因为v只有1字节大小)。
3.4、recoverSigner函数
很简单,利用Solidity的ecrecover函数来从签名和消息中计算签名地址。
3.5、prefixed函数
模拟eth_sign函数的形式,在签名的消息前增加以太坊固定前缀,然后再计算其哈希值。
四、部署合约
将该合约部署在Kovan测试网上,地址为:0x9dC909fa3fD66f79F72E495acc518264b76b079D
,已经浏览器开源验证。
当前部署工具没有什么好的,remix总是很慢,MyEtherWallet改了新版之后一直显示空白(也许是笔者的网络问题)。因此,最保险的办法是自己写个脚本部署了,使用ethers.js
中的ContractFactory来部署就可以了。
五、测试脚本
虽然官方文档上介绍的使用的是web3.js
,但是我平常使用的是ethers.js
,因此使用ethers.js
写的脚本,但是因为自己对某方面不熟悉,所以还是踩坑了,也坑了较久。
直接上源码:
//导入需要的库及函数
const {utils, ethers} = require("ethers")
const {joinSignature} = require("@ethersproject/bytes")
//定义provider
const provider = new ethers.providers.InfuraProvider("kovan","your_infuraKey")
const my_privateKey = "your_privateKey" //部署合约及签名的账号私钥
const another_privateKey = "your_privateKey2" //领取资金的账号私钥
//由私钥创建钱包
const my_wallet = new ethers.Wallet(my_privateKey)
const another_wallet = new ethers.Wallet(another_privateKey,provider)
//构建合约对象
const abi = require("../abis/ReceiverPay");
const contract_address = "0x9dC909fa3fD66f79F72E495acc518264b76b079D"
const pay_contract = new ethers.Contract(contract_address,abi,another_wallet)
async function claimTest() {
let amount = utils.parseEther("0.001") //支付数量,0.001ETH
let nonce = 1 //支付合约中用到的 nonce
//将相应参数打包并计算哈希,得到一个需要签名的消息
let message = utils.solidityKeccak256(["address","uint256","uint256","address"],[another_wallet.address,amount,nonce,contract_address])
console.log("message:",message)
//计算messageHash
//注意这里有个坑
//使用hashMessage函数签名上面得到的哈希时先要哈希转成字节数组,不能直接使用message,否则就是将对应的字符串文字形式转换成字节数组了。
//如果签名一个字符串,这里是不需要使用arrayify进行转换的。
let messageHash = utils.hashMessage(utils.arrayify(message))
console.log("messageHash:",messageHash)
//得到签名对象
let sig = await my_wallet._signingKey().signDigest(messageHash)
//转换为易读的签名字符串
let signature = joinSignature(sig)
console.log("signature:",signature)
console.log("开始Claim交易")
let tx = await pay_contract.claimPayment(utils.parseEther("0.001"),nonce,signature,{
gasLimit:400000
})
await tx.wait()
console.log("Claim交易已经发送,hash:",tx.hash)
let receipt = await provider.getTransactionReceipt(tx.hash)
console.log("Claim交交易状态为:",receipt.status ? "成功" : "失败")
}
claimTest()
这里解释几点:
1、将支付参数压缩打包并哈希,官方文档介绍的是使用ethereumjs-abi
库中的soliditySHA3
函数,我们这里使用ethers
库中的solidityKeccak256
函数,也是一样的,都是模拟Solidity的行为,具体的模拟的是keccak256(abi.encodePacked(msg.sender, amount, nonce, this))
这个代码片断。
2、utils.solidityKeccak256
函数得到的是一个哈希值,但是这个哈希值就是要签名的消息本身,签名消息时还要再计算一次哈希。
3、我们使用hashMessage函数来计算欲签名消息的哈希值。该函数的源码为:
export function hashMessage(message: Bytes | string): string {
if (typeof(message) === "string") { message = toUtf8Bytes(message); }
return keccak256(concat([
toUtf8Bytes(messagePrefix),
toUtf8Bytes(String(message.length)),
message
]));
}
注意:笔者在上面进行hashMessage
计算时,先将第一步得到的message
进行了arrayify
操作转换成了相应的字节数组。为什么要这么做呢?
我们从上面hashMessage源码中可以看到,如果参数是一个字符串形式(例如message结果)。它会使用toUtf8Bytes
函数来直接转换字符串到字节数组,而不是转换16进制到字节数组,这是有区别的。字符串转换估计是根据码点转换的,因为message为一16进制字符串形式,所以它的长度为66(32字节长度为64,再加上"0x"前缀)。我们比如签名一个字符串时可直接使用,例如utils.hashMessage("helloworld")
这个是没有问题的。但是这里是签名一个计算过后的哈希,再这样签名是有问题的。所以必须先转换成字节数组形式。否则,这里的message.length
就是66,这和ReceiverPays
合约中的prefixed
函数不相符了,因为哈希是固定32位字节的。
笔者最开始没有进行arrayify
转换,所以这里坑了较久,因为官方文档使用的是web3.js
,最后查看web3.js
中相应函数的源码进行比较才发现问题。
上述js
文件中的messageHash
就是合约中prefixed
函数的结果,读者可以自行在合约中将该函数的可见性改为public
来进行验证,包括recoverSigner
函数也可以改成public
的。
4、如果签名一个字符串而不是哈希,直接使用myWallet.signMessage("helloworld")
即可。它在内部也会先调用utils.hashMessage
函数进行哈希运算的。
好了,有兴趣的读者可以自己部署一个合约并测试一下。今天的记录到此结束。
以上是关于Solidity示例合约ReceiverPays.sol学习的主要内容,如果未能解决你的问题,请参考以下文章