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函数进行哈希运算的。

好了,有兴趣的读者可以自己部署一个合约并测试一下。今天的记录到此结束。

猜你喜欢

转载自blog.csdn.net/weixin_39430411/article/details/119823027