一、合约概况

继续官网合约示例的学习—— 一个简略的付出通道合约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.反常场景

一起,有以下几点疑问:

  1. 假如B布置合约时不向合约中打钱,那么A提款时会产生什么?
  2. 假如B布置合约时预付的钱不够A实践提款的钱,会产生什么?
  3. 假如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());