一、合约概况
继续官网合约示例的学习—— 一个简略的付出通道合约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,第2次签名信息包括的买卖金额是8个ether。
- case 1: 在布置合约后的第45个区块后,A运用B的第2次签名信息封闭合约,而且收到了8个ether。
- case 2: 在布置合约后的第50个区块后,合约主动封闭,A没有收到ether
- case 3: B在布置合约后的第49个区块时建议延时,设置合约的主动到期时刻为60个区块,到第60个区块后,合约主动封闭。
- case 4: 在布置合约后的第45个区块后,C运用B的第2次签名信息封闭合约,封闭失利。A运用B的第2次签名信息封闭合约,而且收到了8个ether。
- case 5: 在布置合约后的第45个区块后,B运用B的第2次签名信息封闭合约,封闭失利。A运用B的第2次签名信息封闭合约,而且收到了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的第2次签名信息封闭合约,将产生反常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(bytes32hash,uint8v,bytes32r,bytes32s)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**()externalpayable{...}
- 不需求 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());