文章来源《DAPP应用开发指南》
我们在第6章、第7章学习了solidity语言的语法及特性,这一章我们用前面学习的知识来实践开发几个经典的合约。
这些合约实践还涉及这些内容:
如何使用其他人制造的“轮子”(例如如何基于OpenZeppelin开发)、
代币相关的标准(如ERC20、ERC721、ERC777等)以及支付通道的概念。
8.1 OpenZeppelin
OpenZeppelin是以太坊生态中一个非常了不起的项目,OpenZeppelin提供了很多经过社区反复审计及验证的合约模板(如ERC20、ERC721)及函数库(SafeMath),我们在开发过程中,通过复用这些代码,不仅提高了效率,也可以显著提高合约的安全性。
为使用OpenZeppelin库,可以通过npm来安装OpenZeppelin。

安装完成之后,在项目的node_modules/@openzeppelin/contract目录下可以找到合约源码,不同用途的合约分成了11个文件夹,如图8-1所示。
各个文件夹提供的合约功能如下。
·cryptography:提供加密、解密工具,实现了椭圆曲线签名及Merkle证明工具。
·introspection:合约自省功能,说明合约自身提供了哪些函数接口,主要实现了ERC165和ERC1820。
·math:提供数学运算工具,包含Math.sol和SafeMath.sol。
·token:实现了ERC20、ERC721、ERC777三个标准代币。
·ownership:实现了合约所有权。
·access:实现了合约函数访问控制功能。
·crowdsale:实现了合约众筹、代币定价等功能。
·lifecycle:实现声明周期功能,如可暂定、可销毁等操作。
·payment:实现合约资金托管,如支付(充值)、取回、悬赏等功能。
·utils:实现工具方法,如判断是否为合约地址、数组操作、函数可重入的控制等。

图8-1 OpenZeppelin库
本书使用的OpenZeppelin是2.3.0版本,随着版本的升级,内容可能有所变化,OpenZeppelin使用起来很简单,通过import关键字引入对应的代码即可,以下代码为智能合约加入所有权功能。

如果需要修改OpenZeppelin代码,找到OpenZeppelin代码库GitHub地址(https://github.com/OpenZeppelin/openzeppelin-contracts),通过git clone把代码拷贝到本地进行修改。
OpenZeppelin涉及的内容较多,本章只挑选一些最常用的功能进行介绍,包括对整型运算进行安全检查的SafeMath库、地址工具的使用、用来发布合约接口的ERC165,以及3个最常用的代币标准:ERC20、ERC777、ERC721。
8.2 SafeMath安全算数运算
SafeMath针对256位整数进行加减乘除运算添加了额外的异常处理,避免整型溢出漏洞,SafeMath的代码如下:

由于SafeMath是一个库,可以使用using...for...把这几个函数关联到uint256上:

8.3 地址工具
Address.sol提供isContract()函数来判断一个地址是否为合约地址,判断的方法是查看合约是否有相应的关联代码,Address源码如下:

Address.sol使用了第7章介绍的内联汇编来实现,extcodesize函数取得输入参数account地址所关联的EVM代码的字节码长度,因为只有合约账户才有对应的字节码,其长度才大于0。
注意:如果在合约的构造函数中对当前的合约调用isContract,会返回false,因为在构造函数执行完之前,合约的代码还没有保存。
使用Address.sol的示例代码如下。

MyToken合约中,如果接受代币的地址是合约地址,可以进行额外的操作。
8.4 ERC165接口发现
ERC165表示的是EIP165(第165个提案)确定的标准,这里简单介绍一下以太坊上的应用标准是怎么形成的。
以太坊是去中心化网络,任何人都可以提出改进提案(EIP:Ethereum Improvement Proposals),提案就是在EIP GitHub库(地址:https://github.com/ethereum/EIPs)提出一个Issues,Issues的编号就是提案的编号,提案根据解决问题的不同,会分为协议改进和应用标准(通常为合约接口标准)等类型。
协议改进的提案在经过社区投票采纳后,会实现到以太坊的客户端。
而应用标准就是ERC,它的全称是Ethereum Request for Comment(以太坊征求意见稿),它是一个推荐大家使用的建议(不强制使用),是由社区形成的共识标准。
ERC165提案主要用途是声明合约实现了哪些接口,提案的接口定义如下:

实现ERC165标准的合约可以通过supportsInterface接口来查询它是否实现了某个函数,函数的参数interfaceID是函数选择器(参考第7章),当合约实现了函数选择器对应的函数,supportsInterface接口需要返回true,否则为false(特殊情况下,如果参数interfaceID为0xffffffff,也需要返回false)。
ERC165提案同时要求,实现supportsInterface函数消耗的gas应该在30 000 gas以内。
ERC165参考实现OpenZeppelin中的ERC165Reg是对ERC165的一个实现,代码如下:

在上面的实现中,使用了一个mapping来存储合约支持的接口,支持的接口通过调用_registerInterface进行注册(只有注册之后,才能通过supportsInterface查询到),在上面的代码中注册了两个函数,一个是ERC165标准定义的函数supportsInterface,一个是自定义的函数test(),当我们需要实现ERC165标准时,可以继承ERC165Reg,并调用_registerInterface来注册我们自己实现的函数。
8.5 ERC20代币
ERC20 Token是目前最为广泛使用的代币标准,所有的钱包和交易所都是按照这个标准对代币进行支持的。ERC20标准约定了代币名称、总量及相关的交易函数。

ERC20接口定义中,有一些接口是不强制要求实现的(下面的解释说明中标记了可选的接口),ERC20接口各函数说明如下。
·name():(可选)函数返回代币的名称,如“MyToken”。
·symbol():(可选)函数返回代币符号,如“MT”。
·decimals:(可选)函数返回代币小数点位数。
·totalSupply():发行代币总量。
·balanceOf():查看对应账号的代币余额。
·transfer():实现代币转账交易,成功转账必须触发事件Transfer。
·transferFrom():给被授权的用户(合约)使用,成功转账必须触发Transfer事件。
·allowance():返回授权给某用户(合约)的代币使用额度。
·approve():授权用户可代表我们花费多少代币,必须触发Approval事件。
OpenZeppelin实现代码如下:



ERC20.sol包含标准中所有的必须要实现的函数,可选的函数则放在另一个文件ERC20Detailed.sol中(后面也会贴出代码)。
ERC20实现的关键是使用了两个mapping:_balances和_allowances,_balances用来保存某个地址的余额,_allowances用来保存某个地址授权给另一个地址可使用的余额。
transfer()用来实现代币转账的转账,它有两个参数:转账的目标(接收者)及数量。
在执行transfer()的时候(对照_transfer()的实现),主要是修改控制账号余额的_balances变量,修改方法为:
发送方账号(即交易的发起人)的余额减去相应的金额,同时目标账号的余额加上相应的金额,加减法使用了safemath来防止溢出,transfer()的实现需要触发Transfer事件。
approve()函数和transferFrom()函数需要配合使用,使用场景是这样的:我们先通过approve()授权第三方可以转移我们的币,然后第三方通过transferFrom()去转移币。
举一个通俗的例子:假如使用代币来发送工资,总经理就可以授权财务使用部分代币(使用approve()函数),财务把代币发放给员工(使用transferFrom()函数)。
目前最常用的一个场景是去中心化交易(以下简称DEX,它使用智能合约来处理代币之间的兑换)。
假如Bob要使用DEX智能合约用100个代币A购买150个代币B,那么通常操作步骤是:Bob先把100个A授权给DEX,然后调用DEX的兑换函数,在兑换函数里使用transferFrom()函数把Bob的100个A转走,之后再转给Bob150个B。
approve()函数通过修改_allowances变量来控制被授权人及授权代币数量(请对照上面代码的_approve()函数),_allowances[owner][spender]=value;的意思是:owner账号授权spender账号可消费数量为value的代币。
transferFrom()是由被授权人发起调用,transferFrom()的第一个参数sender是真正扣除代币的账号(也就是_allowances中的owner)。
ERC20Detailed的实现比较简单,仅仅初始化代币名称、代币符号、小数位数这3个变量,代码如下:

ERC20实现
有了ERC20.sol和ERC20Detailed.sol,实现一个自己的代币就很简单了,现在我们实现一个有4位小数、名称为My Token的代币,只需要以下几行代码:

第6行的_mint()函数是在ERC20.sol中实现的,用来初始化代币发行量。
8.6 ERC777功能型代币
ERC20代币简洁实用,非常合适用它来代表某种权益,不过有时候在ERC20添加一些功能就会显得有些力不从心,举两个典型的场景:
(1)使用ERC20代币购买商品时,ERC20合约上无法记录购买具体商品的信息,那就需要额外用其他的方式记录,势必增加整个过程的成本。
(2)在经典的“存币生息”Defi应用中,理想的情况是代币在转入存币生息合约之后,后者就开始计息,然而由于ERC20代币的缺陷,存币生息合约实际上无法知道有人向它转账,因此也无法开始计息。
如果要解决场景(2)的问题,在ERC20标准中必须把存币生息分解为两步: 第一步:让用户用approve()函数授权存币生息合约可以转移用户的币; 第二步:再次让用户调用存币生息合约的计息函数,计息函数中通过transferFrom把代币转移到自身合约内,开始计息。 |
除此之外,ERC20还有一个缺陷:ERC20误转入一个合约后,如果目标合约没有对代币作相应的处理,则代币将永远锁死在合约里,没有办法把代币从合约里取出来。
ERC777很好地解决了这些问题,同时ERC777也兼容ERC20标准。
建议大家在开发新的代币时使用ERC777标准。
ERC777定义了send(dest, value, data)函数来进行代币的转账。
| ERC777标准特意避开和ERC20标准使用同样的transfer()函数,这样就能让用户同时实现两个函数以兼容两个标准。 |
send()函数有一个额外的参数data用来携带转账的附加信息,同时send函数在转账时还会对代币的持有者和接收者发送通知,以方便在转账发生时,持有者和接收者可以进行额外的处理。
| 代币的持有者和接收者需要实现额外的函数才能收到转账通知。 |
send函数的通知是通过ERC1820接口注册表合约来实现的,所以我们先介绍ERC1820。
8.6.1 ERC1820接口注册表
前文介绍的ERC165标准可以声明合约实现了哪些接口,却没法为普通账户地址声明实现了哪些接口。
ERC1820标准通过一个全局的注册表合约来记录任何地址声明的接口,其实现机制类似于Windows的系统注册表,注册表记录的内容包含地址(声明实现接口的地址)、注册的接口、接口实现在哪个合约地址(可以和第一个地址一样)。
ERC1820是一个全局的合约,它在链上有一个固定的合约地址,并且在所有的以太坊网络(包含测试、以太坊经典等)上都具有相同合约地址,这个地址总是:0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24,因此总是可以在这个合约上查询地址实现了哪些接口。
ERC1820是通过非常巧妙的方式(被称为无密钥部署方法)部署的。 有兴趣可以阅读ERC1820标准-部署方法部分,链接:https://learnblockchain.cn/docs/eips/eip-1820.html。 |
需要注意的是,ERC1820标准是一个实现了的合约,前面讲到的如ERC20标准定义的是接口,需要用户来实现部署(例如参考OpenZeppelin的模板来实现)。
对于ERC1820合约,除了地址、接口、合约三个部分,还需要了解几个要点。
(1)ERC1820引入了管理员角色,由管理员来设置哪个合约在哪个地址实现了哪一个接口。
(2)ERC1820要求实现接口的合约,必须实现canImplementInterfaceForAddress函数,来声明其实现的接口,并且当用户查询其实现的接口时,必须返回常量ERC1820_ACCEPT_MAGIC。
(3)ERC1820也兼容ERC165,即也可以在ERC1820合约上查询ERC165接口,为此ERC1820使用了函数签名的完整Keccak256哈希来表示接口(下方代码的interfaceHash),而不是ERC165接口定义的前4个字节的函数选择器。
在了解上面的要点后,理解下方ERC1820合约的官方实现代码就比较容易了,看看它是如何实现接口注册的。
为了方便理解,代码中已经加入注释。




ERC1820合约中的两个函数——setInterfaceImplementer和getInterfaceImplementer最值得关注,setInterfaceImplementer用来设置某个地址(参数_addr)的某个接口(参数_interfaceHash)由哪个合约实现(参数_implementer),检查状态成功后,信息会记录到interfaces映射中(interfaces[addr][_interfaceHash]=_implementer;),getInterfaceImplementer则是在interfaces映射中查询接口的实现。
另一方面,如果一个合约要为某个地址(或自身)实现某个接口,则需要实现下面这个接口。

在合约实现ERC1820ImplementerInterface接口后,如果调用canImplementInterface ForAddress返回ERC1820_ACCEPT_MAGIC,这表示该合约在地址(参数addr)上实现了interfaceHash对应的接口,在ERC1820合约中的setInterfaceImplementer函数在设置接口实现时,会通过canImplementInterfaceForAddress检查合约是否实现了接口。
8.6.2 ERC777标准
本节的主题是ERC777,因为ERC777依赖ERC1820来实现转账时对持有者和接受者的通知,才插入了上面ERC1820的介绍。
回到ERC777,我们先通过ERC777的接口定义来进一步理解ERC777标准。



接口定义在代码库(https://github.com/OpenZeppelin/openzeppelin-contracts)路径为contracts/token/ERC777/IERC777.sol的文件中。
所有的ERC777合约必须实现上述接口,同时通过ERC1820标准注册ERC777Token接口,注册方法是:调用ERC1820注册合约的setInterfaceImplementer方法,参数_addr及_implementer均是合约的地址,_interfaceHash是“ERC777Token”的keccak256哈希值(0xac7fbab5f54a3ca8194167523c6753bfeb96a445279294b6125b68cce2177054)。
ERC777与ERC20代币标准保持向后兼容,因此标准的接口函数是分开的,可以选择一起实现,ERC20函数应该仅限于从老合约中调用,ERC777要实现ERC20标准,同样通过ERC1820合约调用setInterfaceImplementer方法来注册ERC20Token接口,接口哈希是ERC20 Token的keccak256哈希(0xaea199e31a596269b42cdafd93407f14436db6e 4cad65417994c2eb37381e05a)。
ERC777标准的name()、symbol()、totalSupply()、balanceOf(address)函数的含义和ERC20中完全一样,granularity()用来定义代币最小的划分粒度(>=1),必须在创建时设定,之后不可以更改。
它表示的是代币最小的操作单位,即不管是在铸币、转账还是销毁环节,操作的代币数量必需是粒度的整数倍。
granularity和ERC20的decimals函数不一样,decimals用来定义小数位数,是内部存储单位,例如,0.5个代币在合约里存储的值为500 000 000 000 000000(0.5×1018)。 decimals()是ERC20可选函数,为了兼容ERC20代币,decimals函数要求必须返回18。 而granularity表示的是最小操作单位,它是在存储单位上的划分粒度,如果粒度granularity为2,则必须将2个存储单位的代币作为一份来转账。 |
操作员
ERC777引入了一个操作员角色(前文所说接口的operator),操作员定义为操作代币的角色。
每个地址默认是自己代币的操作员。
不过,将持有人和操作员的概念分开,可以提供更大的灵活性。
| 与ERC20中的approve、transferFrom不同,ERC20未明确定义批准地址的角色。 |
此外,ERC777还可以定义默认操作员(默认操作员列表只能在代币创建时定义的,并且不能更改),默认操作员是被所有持有人授权的操作员,这可以为项目方管理代币带来方便。
当然,持有人也有权撤销默认操作员。操作员相关的函数有以下几个。
·defaultOperators():获取代币合约默认的操作员列表。
·authorizeOperator(address operator):设置一个地址作为msg.sender的操作员,需要触发AuthorizedOperator事件。
·revokeOperator(address operator):移除msg.sender上operator操作员的权限,需要触发RevokedOperator事件。
·isOperatorFor(address operator, address holder):验证是否为某个持有者的操作员。
发送代币
发送代币功能上和ERC20的转账类似,但是ERC777的发送代币可以携带更多的参数,ERC777发送代币使用以下两个方法:

operatorSend可以通过参数operatorData携带操作者的信息,发送代币除了执行持有者和接收者账户的余额加减和触发事件之外,还有下列规定:
(1)如果持有者有通过ERC1820注册ERC777TokensSender实现接口,ERC777实现合约必须调用其tokensToSend()钩子函数(英文中称为Hook函数)。
(2)如果接收者有通过ERC1820注册ERC777TokensRecipient实现接口,ERC777实现合约必须调用其tokensReceived()钩子函数。
(3)如果有tokensToSend()钩子函数,必须在修改余额状态之前调用。
(4)如果有tokensReceived()钩子函数,必须在修改余额状态之后调用。
(5)调用钩子函数及触发事件时,data和operatorData必须原样传递,因为tokensToSend和tokensReceived函数可能根据这个数据取消转账(触发revert)。
如果持有者希望在转账时收到代币转移通知,需要实现ERC777TokensSender接口,ERC777TokensSender接口定义如下:

此接口定义在代码库的路径为contracts/token/ERC777/IERC777Sender.sol的文件中。
在合约实现tokensToSend()函数后,调用ERC1820注册表合约上的setInterface Implementer(address _addr, bytes32 _interfaceHash, address _implementer)函数,_addr使用持有者地址,_interfaceHash使用ERC777TokensSender的keccak256哈希值(0x29d db589b1fb5fc7cf394961c1adf5f8c6454761adf795e67fe149f658abe895),_implementer使用的是实现ERC777TokensSender的合约地址。
有一个地方需要注意:对于所有的ERC777合约,一个持有者地址只能注册一个合约来实现ERC777TokensSender接口。
但是实现ERC777TokensSender接口的合约可能会被多个ERC777合约调用,在tokensToSend函数的实现合约里,msg.sender是ERC777合约地址,而不是操作者。
如果接收者希望在转账时收到代币转移通知,需要实现ERC777TokensRecipient接口,ERC777TokensRecipient接口定义如下:

接口定义在代码库的路径为contracts/token/ERC777/IERC777Recipient.sol的文件中。
在合约实现ERC777TokensRecipient接口后,使用和上面一样的方式注册,不过接口的哈希使用ERC777TokensRecipient的keccak256哈希值(0xb281fc8c12954d22544db 45de3159a39272895b169a852b314f9cc762e44c53b)。
如果接收者是一个合约地址,则合约必须要注册及实现ERC777TokensRecipient接口(这可以防止代币被锁死),如果没有实现,ERC777代币合约需要回退交易。
铸币与销毁
铸币(挖矿)是产生新币的过程,销毁代币则相反。
| 在ERC20中,没有明确定义这两个行为,通常会用transfer方法和Transfer事件来表达。来自全零地址的转账是铸币,转给全零地址则是销毁。 |
ERC777则定义了代币从铸币、转移到销毁的整个生命周期。
ERC777没有定义铸币的方法名,只定义了Minted事件,因为很多代币是在创建的时候就确定好了代币的数量。
如果有需要,合约可以定义自己的铸币函数,ERC777要求在实现铸币函数时必须要满足以下要求:
(1)必须触发Minted事件;
(2)发行量需要加上铸币量,如果接收者是不为0的地址,则把铸币量加到接收者的余额中;
(3)如果接收者有通过ERC1820注册ERC777TokensRecipient实现接口,代币合约必须调用其tokensReceived()钩子函数。
ERC777定义了两个函数用于销毁代币(burn和operatorBurn),可以方便钱包和DAPPs有统一的接口交互。
burn和operatorBurn的实现同样有要求:
(1)必须触发Burned事件;
(2)总供应量必须减去代币销毁量,持有者的余额必须减少代币销毁的数量;
(3)如果持有者通过ERC1820注册了ERC777TokensSender接口的实现,必须调用持有者的tokensToSend()钩子函数;
注意:0个代币数量的交易(不管是转移、铸币与销毁)也是合法的,同样满足粒度(granularity)的整数倍,因此需要正确处理。
8.6.3 ERC777实现
可以看出ERC777在实现时相比ERC20有更多的要求,增加我们实现的难度,幸运的是,OpenZeppelin帮我们做好了模板,以下是OpenZeppelin实现的ERC777合约模板:











大家可以在OpenZeppelin代码库的路径为contracts/token/ERC777/ERC777.sol的文件中找到以上代码。
以上是一个模板实现,基于ERC777模板,可以很容易实现一个自己的ERC777代币,例如实现一个发行21 000 000个的M7代币的代码示例如下。

8.6.4 实现钩子函数
前面我们介绍了如果想要收到转账等操作的通知,就需要实现钩子函数,如果不需要通知,普通账户之间是可以不实现钩子函数的,但是转入到合约则要求合约一定要实现ERC777TokensRecipient接口定义的tokensReceived()钩子函数,我们假设有这样一个需求:
寺庙实现了一个功德箱合约,功德箱合约在接受代币的时候要记录每位施主的善款金额。
实现ERC777TokensRecipient下面就来实现下功德箱合约,示例代码如下。

功德箱合约在构造的时候,调用ERC1820注册表合约的setInterfaceImplementer注册接口,这样在收到代币时,会调用tokensReceived函数,tokensReceived函数通过givers mapping来保存每个施主的善款金额。
注意:如果是在本地的开发者网络环境,可能会没有ERC1820注册表合约,如果没有,需要先部署ERC1820注册表合约。
代理合约实现ERC777TokensSender
如果持有者想对发出去的代币有更多的控制,可以使用一个代理合约来对发出的代币进行管理,假设这样一个需求,如果发现接收的地址在黑名单内,转账进行阻止,来看看如何实现。根据ERC1820标准,只有账号的管理者才可以为账号注册接口实现合约,在刚刚实现ERC777TokensRecipient时,由于每个地址都是自身的管理者,因此可以在构造函数里直接调用setInterfaceImplementer设置接口实现,按照刚刚的假设需求,实现ERC777TokensSender有些不一样,代码如下:



这个合约要代理某个账号完成黑名单功能,按照前面ERC1820要求,在调用setInterfaceImplementer时,如果一个msg.sender和实现合约不是一个地址时,则实现合约需要实现canImplementInterfaceForAddress函数,并对实现的函数返回ERC1820_ACCEPT_MAGIC。
剩下的实现就很简单了,合约函数setBlack()用来设置黑名单,它使用一个mapping状态变量来管理黑名单,在tokensToSend函数的实现里,先检查接收者是否在黑名单内,如果在,则revert回退交易,阻止转账。
给账号(假设为A)设置代理合约的方法为:先部署代理合约,获得代理合约地址,然后用A账号去调用ERC1820的setInterfaceImplementer函数,参数分别是A的地址、接口的keccak256即0x29ddb589b1fb5fc7cf394961c1adf5f8c6454761adf795e67fe149f658abe895以及代理合约地址。
通过实现ERC777TokensSender和ERC777TokensRecipient可以延伸出很多有意思的玩法,各位读者可以自行探索。
8.7 ERC721
前面介绍的ERC20及ERC777,每一个币都是无差别的,称为同质化代币,总是可以使用一个币去替换另一个币,现实中还有另一类资产,如独特的艺术品、虚拟收藏品、歌手演唱的歌曲、画家的一幅画、领养的一只宠物。
这类资产的特点是每一个资产都是独一无二的,且不可以再分割,这类资产就是非同质化资产(Non-Fungible),ERC721就使用Token来表示这类资产。
8.7.1 ERC721代币规范

如果合约(应用)要接受NFT的安全转账,则必须实现以下接口。

以下元信息(描述代币本身的信息)扩展是可选的,但是可以提供一些资产代表的信息以便查询。

以下是“ERC721元数据JSON Schema”描述:

非同质资产不能像账本中的数字那样“集合”在一起,每个资产必须单独跟踪所有权,因此需要在合约内部用唯一uint256 ID标识码来标识每个资产,该标识码在整个合约期内不得更改。
标准并没有限定ID标识码的规则,不过开发者可以选择实现下面的枚举接口,方便用户查询NFTs的完整列表。

8.7.2 ERC721实现
以下是OpenZeppelin实现的ERC721,代码可以在openzeppelin合约代码库[插图]的token/ERC721目录下找到。







以下是元信息实现:






8.8 简单的支付通道
上面案例都是基于OpenZepplin库代码来实现的,这节我们来独立实现一个支付通道,支付通道是一个链上链下相互结合的案例。
我们通过一个场景来理解它,假设有这样一个场景,小明经常要去楼下的咖啡店喝咖啡,小明每次除了支付0.05以太币的咖啡费用之外,还需要支付一笔手续费给矿工。
为了节约手续费,小明可以在他与咖啡店之间创建一个支付通道,通过加密签名来实现重复安全的以太币转账,而不用每次都支付手续费。
小明可以这样做:
·创建一个支付通道合约,并存入2个以太币(链上进行)。
·每次买咖啡时签名一条交易信息给老板,交易信息包含的内容有:
总共要支付多少钱给老板及签名数据本身。
这是在链下进行的,不用支付手续费。
·每次买咖啡时时,小明都重复步骤2(只需要不超出2个以太币),而老板任何时候都可以把小明的签名信息发送给链上的支付通道合约,提取小明支付的咖啡费用(同时也意味着老板不想后续收款,选择关闭支付通道)。
·小明不想喝咖啡了,取回支付通道的余额。
通过这样一条支付通道,小明可以节约大量的手续费。
我们看看它如何实现。
8.8.1 创建支付通道智能合约
首先,由小明创建一个支付通道智能合约,支付通道合约指定费用的接收者以及合约有效期。
合约代码如下(各函数的说明直接以注释形式给出)。



小明在创建合约时,就需要打入以太币,因此constructor()构造函数需要用payable修饰,小明支付咖啡费是通过转给店主一个签名的信息(就像小明给了一张签名的支票到店主一样,下一节会介绍如何进行支付签名),然后店主使用签名信息调用合约的close()函数进行提款。
close()函数会先验证签名信息的有效性(防止店主伪造信息),然后再放款。
同时为了安全性的考虑,合约加入了一个有效期,要求咖啡店必须在有效期内进行收款,如果没有消费或咖啡店主一直不收款,小明可取回资金。
8.8.2 支付签名
小明向店主发送付款的签名信息,小明用自己的私钥签名,然后直接传输给店主,这是在线下进行的,而非以太坊上的链上交易。
每条签名信息需要包含以下信息:
·智能合约的地址,用于防止交叉合约重放攻击(防止一个支付通道的消息被用于不同的通道)。
·到目前为止所要支付的以太币总数。
在很多次支付之后,如果店主想要提取资金,他就可以使用最后一次签名信息(包含累计消费金额)提交到智能合约(调用合约的close函数),一次赎回所有的资金。
我们已经知道哪些信息需要包含到签名信息里,需要先把这些信息合并在一起,然后计算哈希,最后进行签名,以下是JavaScript用来构造签名信息的代码:

constructPaymentMessage函数使用了ethereumjs-abi库(代码:https://github.com/ethereumjs/ethereumjs-abi)的soliditySHA3用来进行信息拼接与哈希,signMessage函数使用Web.js的eth.personal.sign函数进行签名。
这样我们就完成了让合约来担当支付通道的角色,当然,本案例还有一些不完善的地方,比如店家需要有方法在每次收款时及时验证小明签名的正确性,读者可以思考如何实现。

