一、合约详情
继续官网合约示例的学习—— 一个简单的支付通道合约learnblockchain.cn/docs/solidi… 参考版本:0.8.17
官网示例中的ReceiverPays 合约是一个实现让提款人在链上安全提款的合约,提款时的信息是经过付款人签名加密的,此信息可以通过链下方式传递给提款人,提款人进行提款操作时仅需要携带最后一次的正确提款信息,即可从合约中提取到付款人支付的金额。
此合约中用到了密码学验证交易签名、也用到了内联汇编方式来提高验证签名函数的执行效率。
按照ReceiverPays的设计理念拓展一下,就可以得到简单的支付通道的合约了,SimplePaymentChannel合约代码如下:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract SimplePaymentChannel {
address payable public sender; // The account sending payments.
address payable public recipient; // The account receiving the payments.
uint256 public expiration; // Timeout in case the recipient never closes.
constructor (address payable recipientAddress, uint256 duration)
public
payable
{
sender = payable(msg.sender);
recipient = recipientAddress;
expiration = block.timestamp + duration;
}
function isValidSignature(uint256 amount, bytes memory signature)
internal
view
returns (bool)
{
bytes32 message = prefixed(keccak256(abi.encodePacked(this, amount)));
// check that the signature is from the payment sender
return recoverSigner(message, signature) == sender;
}
/// the recipient can close the channel at any time by presenting a
/// signed amount from the sender. the recipient will be sent that amount,
/// and the remainder will go back to the sender
function close(uint256 amount, bytes memory signature) external {
require(msg.sender == recipient);
require(isValidSignature(amount, signature));
recipient.transfer(amount);
selfdestruct(sender);
}
/// the sender can extend the expiration at any time
function extend(uint256 newExpiration) external {
require(msg.sender == sender);
require(newExpiration > expiration);
expiration = newExpiration;
}
/// 如果过期过期时间已到,而收款人没有关闭通道,可执行此函数,销毁合约并返还余额
function claimTimeout() external {
require(block.timestamp >= expiration);
selfdestruct(sender);
}
/// All functions below this are just taken from the chapter
/// 'creating and verifying signatures' chapter.
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));
}
}
思考一下:为什么需要有这种微支付通道的合约呢?
现实中有这样的需求场景,有可能A是一个商家,支持用以太支付,A店里有常客B经常发生消费,B就可以部署一个微支付通道合约,每次支付时并不是真的发起链上交易,而是通过链下发信息给A,A在有效期前用B最后发送的消息提走合约里的钱即可。虽然B每笔交易都发了消息给A,但是A仅使用最后一次消息即可提款,因为每次消息中,都包含部署合约以来的累计支付的总金额。这样在时间维度和订单维度上进行合并,在一个时间段内仅支付一次就可以了,这样就省去了中间频繁的转账交易,也能省gas费,还可以规避以太坊主网的交易拥堵情况。
二、用例设计
我仍将进行一组简单的测试用例设计,用来调用此合约,并验证合约功能的正确性。
1. 正常场景
前置条件:B部署支付通道合约并转入10个ether,设置合约自动到期时间为据当前50个区块后,B在此期间向A发送了两笔签名信息,第一次签名信息包含的交易金额是3个ether,第二次签名信息包含的交易金额是8个ether。
- case 1: 在部署合约后的第45个区块后,A使用B的第二次签名信息关闭合约,并且收到了8个ether。
- case 2: 在部署合约后的第50个区块后,合约自动关闭,A没有收到ether
- case 3: B在部署合约后的第49个区块时发起延时,设置合约的自动到期时间为60个区块,到第60个区块后,合约自动关闭。
- case 4: 在部署合约后的第45个区块后,C使用B的第二次签名信息关闭合约,关闭失败。A使用B的第二次签名信息关闭合约,并且收到了8个ether。
- case 5: 在部署合约后的第45个区块后,B使用B的第二次签名信息关闭合约,关闭失败。A使用B的第二次签名信息关闭合约,并且收到了8个ether。
- case 6: 在第20个区块后,B调用claimTimeout方法关闭合约失败,发生revert。
2.异常场景
同时,有以下几点疑问:
- 如果B部署合约时不向合约中打钱,那么A提款时会发生什么?
- 如果B部署合约时预付的钱不够A实际提款的钱,会发生什么?
- 如果B部署合约时预付的钱不够,后来又向合约中打钱,A再进行提款,会放生什么?
根据这几个疑问设计异常场景的用例:
- case 7: B部署合约时不向合约中打钱,预期结果:A在关闭通道时将发生异常revert
- case 8: B部署合约时,向合约中转入5个以太,在部署合约后的第45个区块后,预期结果:A使用B的第二次签名信息关闭合约,将发生异常revert
- case 9: B部署合约时,向合约中转入5个以太,在部署合约后的第45个区块后,B又向此合约转入5个以太,预期结果:会转账失败。(因为部署合约中没有用于接收以太的方法)
用hardhat进行测试脚本的编写,验证用例全部符合预期结果。
三、实战经验
1.关于签名和验签
签名和验签的可靠性,是保障支付通道合约能够安全运行的必要前提。对于合约代码中签名和验签的设计,我梳理了使用流程,但实际上关于椭圆曲线签名算法的部分,我还不太理解,只能做到依葫芦画瓢的使用。
(1)签名过程
在测试代码中,可以用以下方式得到B支付以太的签名信息,信息原文包含支付通道合约地址和支付金额
const hash1=ethers.utils.solidityKeccak256(["address","uint256"],[paymentInstance.address,parseEther("3")]);
const sig1=await b.signMessage(ethers.utils.arrayify(hash1));
(2)验签过程
在SimplePaymentChannel合约代码中验签过程在isValidSignature函数中,包含的步骤:
- 组装消息message(固定请求头+此合约address+金额)
- 把massage和接受到的签名传入recoverSigner函数,将得到签名者地址
- recoverSigner函数中先用内联汇编代码块拆解签名,(uint8 v, bytes32 r, bytes32 s) = splitSignature(sig),再将v,r,s和组装的消息message传入Solidity的内建函数ecrecover中,就可以得到签名者的地址
- 最后判断上面得到的签名者地址和msg.sender是否为同一地址即可
(3)关于内建函数ecrecover
ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address)
利用椭圆曲线签名恢复与公钥相关的地址,错误返回零值。 函数参数对应于 ECDSA签名的值:
- r = 签名的前 32 字节
- s = 签名的第2个32 字节
- v = 签名的最后一个字节
2. 关于钱如何打入合约
正常情况:建立支付通道的人,在部署合约时,转入足够的ether。 异常情况:上述用例设计部分的case7, case8 ,case9,依次是部署合约不转入ether,部署合约时存入的ether不足和部署合约后又向合约转入ether的情况。
在编写测试脚本时,对“向合约中转入以太”的经验总结如下:
(1)合约的构造函数有payable
在示例合约中,构造函数有payable修饰,因此可以直接在部署合约时,转入ether:
constructor (address payable recipientAddress, uint256 duration)
public
payable
{
sender = payable(msg.sender);
recipient = recipientAddress;
expiration = block.timestamp + duration;
}
部署合约时转入以太的用法:
const paymentInstance=await payment.connect(b).deploy(a.address,expireTime,{value:parseEther("10")});
(2)合约可以接收以太的条件
至少有receive函数或fallback函数
-
receive函数是目前推荐的用法
- 一个合约中只能有一个receive函数
- Solidity 0.6.0之后,用receive函数接受以太,函数声明为: **
receive**() external payable { ... } - 不需要 function 关键字,也没有参数和返回值并,且必须是 external 可见性和 payable 修饰. 它可以是 virtual 的,可以被重载也可以有 修改器modifier 。
- receive函数只有2300gas可用,除了基础的日志输出之外,进行其他操作的余地很小
-
fallback函数的用法(不推荐)
- 合约可以最多有一个回退函数。函数声明为: fallback () external [payable] 或 fallback (bytes calldata input) external [payable] returns (bytes memory output)
- 没有 function 关键字。 必须是 external 可见性,它可以是 virtual 的,可以被重载也可以有 修改器modifier
- 如果在一个对合约调用中,没有其他函数与给定的函数标识符匹配fallback会被调用. 或者在没有 receive函数时,也没有提供附加数据就对合约进行调用(即纯以太的转账),那么fallback 函数会被执行
- fallback函数在接收以太这种用法时,只有2300gas可用。
如果想要让示例合约满足中途补充以太的方法,可以选择加receive函数或者fallback函数的方式,比如用加receive的方式,新增方法:
receive() external payable {
console.log("receive value: %s", msg.value);
}
部署合约后中途转账进合约的方式:
//部署时,转入5个ether
const paymentInstance=await payment.connect(b).deploy(a.address,50*15,{value:parseEther("5")});
// do sth
// ...
//中途再转入10个ether
await b.sendTransaction({to:paymentInstance.address,value:parseEther("10")});
3.关于结果校验
(1)对余额进行验证
要验证“在A关闭通道后,A账户余额增加8,同时B账户余额增加2”
使用方式1:
const before_a= ethers.utils.formatEther(await a.getBalance());
const before_b= ethers.utils.formatEther(await b.getBalance());
paymentInstance.connect(a).close(parseEther("8"),sig2)
const after_a= ethers.utils.formatEther(await a.getBalance());
const after_b= ethers.utils.formatEther(await b.getBalance());
assert.equal(Math.round(after_a-before_a),8);
assert.equal(Math.round(after_b-before_b),2);
使用方式2: 引入工具hardhat-chai-matchers
const { changeEtherBalance } = require("@nomicfoundation/hardhat-chai-matchers");
await expect (paymentInstance.connect(a).close(parseEther("8"),sig2))
.to.changeEtherBalances([a,b],[parseEther("8"),parseEther("2")]);
(2)对于链上block和时间的获取
引入工具hardhat-network-helpers
const { time } = require('@nomicfoundation/hardhat-network-helpers');
console.log("当前区块高度是:",await time.latestBlock());
console.log("当前区块时间是:",await time.latest());