大龙说币-ETH的实践
ETH拥堵对于批量发送交易带来的困扰
对于ETH应用开发者而言,ETH网络的拥堵状况,交易手续费都是在设计开发时必须要考虑的点。
先来了解下ETH处理交易的性能:ETH理论的交易处理速度是每秒30笔,但目前的出一个块的平均时间大概是14秒,平均每个块包含一百多笔交易,也就是说,目前ETH每秒能打包的交易大概是10笔。
在ETH上发起一笔交易,矿工将这笔交易打包到新的区块中是需要收取手续费的, 手续费又由哪些因素决定呢?先来了解下ETH网络中的Gas(燃料):
Gas用来衡量执行交易需要多少“工作量”,这些“工作量”就是为了执行该动作支付给ETH矿工的费用额。当发起某笔交易时,发送方设定了Gas Limit 和 Gas Price。Gas Limit表示发送方愿意为了执行这笔交易最多付出的Gas数量,而Gas Price是发送方给单位Gas设置的价格。用户发起一笔交易好比发出一趟车,执行交易的复杂度类比成车要行驶的里程,交易成功好比这辆车跑完整个行程。Gas Limit是发车时的给油箱加的油量,Gas Price就是每升汽油的价格,发车前必须先加好油,如果油不够(Gas Limit不足),这趟车半路会抛锚(交易执行失败)。当这辆车跑完行程后,实际消耗的油量就是Used Gas,邮箱里剩下的油会按加油时的原价返还给用户。用户最后支付的油钱(Used Gas * Gas Price)就是加油站(矿工)所得。大致原理如图所示:
txfee.jpg
可是矿工在一个区块中打包交易的数量是有限的(Block Gas Limit), 为了自身利益的最大化,矿工通常会优先打包那些Gas Price更高的交易。我们为了使发出的交易能尽快获得确认,会设置合理的Gas Price。合理的Gas Price是个动态的值,节点计算最近的一定数量的区块中所有交易的平均Gas Price,再乘以一个系数,结果即是Standard Gas Price。在ETH不拥堵的情况下,节点给到的Standard Gas Price通常在 1~10 Gwei之间,但当ETH开始变得拥堵,用户们为了自己发出的交易能优先被矿工打包,会给出更高的Gas Price。可矿工打包的效率并不会因为大家都给得高就加快,用户又纷纷设定更高Gas Price,节点给出的Standard Gas Price会被一路推高。在拥堵高峰时,这个值会是平常是数十倍甚至上百倍之多。这意味在ETH拥堵的情况下,我们要出更多的手续费去和他人竞争,如果你舍不得钱的话,设置的Gas Price不合理,你的交易会待在交易池里无人问津。
gas.jpeg
上图为监测到的gas price数据,蓝线来自于etherscan.io的Safe Price, 绿线则是ETH节点默认配置下给出的Standard Gas Price。Safe Price由etherscan.io的节点计算而出,我们在发送交易设置时,设置的Gas Price尽量不要低于Safe Price,要不然在很有可能在etherscan.io的交易池中暂时找不出这笔交易的详细信息。Standard Gas Price通常要高于etherscan.io给出的Safe Price,是相对合理的价格,发送交易时把Gas Price设为它,交易通常在数分钟内到账。
在开发ETH钱包,糖果盒子,交易所等会进行批量交易的应用时,虽然可以通过累加nonce值来一次性发出多笔交易,但发出的交易只是被放进了未确认交易池里而已,只有被打包进区块的交易我们才能认为是成功到账的,而交易何时能被打包还取决于当前ETH网络的拥堵情况,你为交易设置的Gas Price。这里提一下ETH中的nonce:ETH账户体系的是这样设计的,从一个地址发出的交易会在交易信息里设置一个顺序编号,编号从0开始,每有一笔该地址发出的交易被打包进区块,在发起下一笔时,就要加上1,这个起到记录某地址发送历史发送交易数量的,并能对过往交易排序的顺序编号即为nonce。假设地址A之前一共发送过9笔交易都成功到账,现在又要构建一笔由A发出的新交易,交易里的nonce便要设为9。如果不设置成9,而是设置为10,矿工会一直等待nonce为9的交易出现,那这笔nonce为10是不可能被打包进区块的。那在发出nonce为9的交易后,不能这笔交易确认,接连发出的nonce为10,11,12的交易,nonce值更高的交易不会比nonce值低的交易优先打包,只可能在nonce更低的叫被打包之后,或者与其同时被打包。再假设发出nonce为9的交易,在其被打包前,又构造一笔nonce为9的交易发出,这两笔交易各自在ETH网络上广播,节点同时收到nonce值相同的未确认交易,这时产生了冲突,节点默认会比较两笔交易的Gas Price,将Gas Price相对较高的交易放入交易池,较低那笔则会被舍弃并不再广播。
txpool.jpg
如图所示,在由地址A发出的且已经被打包进区块的交易已有10笔,nonce的值为9(从0开始)。所以交易池中nonce为10,11,12的交易接下来都有可能按序被矿工打包,这些未确认交易处于pending状态。但因为缺少nonce为13的交易,那nonce为14的交易暂时不可能被矿工列入打包的候选交易,处于queued状态。待nonce为13的交易出现在交易池中时,nonce为14的这笔交易才有可能被打包。
所以从某个地址批量发出交易时,pending队列里nonce值靠前的某笔交易的Gas Price过低,迟迟不能被矿工打包,那么nonce在其后的未确认交易都不可能被打包。如果不去解决前面被拥堵的交易,还持续不间断地从该地址发出交易,哪怕后面这些交易给出了特别高的Gas Price,也不能促使矿工去打包。这么做往交易池的pending队列里塞入了更多的交易,反而会使ETH愈加拥堵。
拥堵时可以采取的策略
ETH网络出现拥堵时,我们也不可能因此暂停掉业务。那么从某地址批量发出交易时,不要无节制地往pending队列塞入交易。通常我们会设置一个水位,当某个地址在交易池的pending队列中有50笔未确认交易时,就应该暂时停止用该地址发送交易,然后等待这50笔未确认交易被矿工打包消化光后,再重新开启。另外执行批量操作时,可以使用多个热钱包地址去发出交易,避免单个地址被其某笔交易堵塞而导致整个功能模块暂停。还有,如果某笔未确认的交易阻塞已久,重新构建这笔交易并设置更高更合理的Gas Price然后发出,矿工节点会用这笔新的交易替换掉原先被阻塞的,往往能解决队列拥堵的问题,这种动态补偿机制也为诸多交易所,钱包所使用。
上面的方案是为了在ETH拥堵时保证应用的正常运行和用户体验,本质上提高了Gas Price,加大了手续费的消耗。Gas Price由市场决定,我们并没有定价权,那在Gas Price无法节省的前提下,批量发送交易时所需要的Used Gas能否减少呢?如果能有什么办法减少单笔交易的Gas消耗,也就变相降低了手续费。
合约层面的批量转账
假设从一个地址往10个地址发送某ERC20 Token,目前我们是针对每个地址去创建一笔transfer交易,批量产生了10笔交易然后发出。那有没有什么办法能像BTC那样,在一笔交易里写入多笔转账操作呢,兴许这样能降低总的Gas消耗?
一些Token在合约里就实现了批量转账的batchTransfer方法,该方法接收数组,然后开始循环,针对数组中的每个地址进行发起一笔转账。让我们看一下BEC对batchTransfer的实现:
becbug.png
逻辑上是OK的,但很遗憾,BEC的开发团队在这里出了个极其严重的Bug:*运算后,没有对结果进行溢出判断,存在严重风险,可为攻击者利用来凭空生成代币。batchTransfer也并不是ERC20代币的标准,某些Token实现了这个方法,某些Token本着越简单越安全的原则,没有在Token的合约里去实现类似的批量转账功能。如果你要在Token合约内实现,请务必要做好代码的安全审计。
作为第三方应用开发,Token的合约不是我们能决定的,绝大多数的Token也没有在合约内实现batchTransfer功能,我们还能通过其他的方式在合约层面实现批量转账吗?
首先,ETH是无法在一笔交易里直接去多次调用合约或者同时调用多个合约的,但是,合约内的函数是可以外部调用其他的合约的。我们可以将批量转账的逻辑以一个智能合约来实现,通过该合约的去多次外部调用Token合约内transfer方法,这样我们的目的也就达到了,原理如下图所示:
call.jpg
地址B上是一个支持ERC20标准的代币合约,提供有transfer方法,我们可以再部署一个中间合约来实现批量调用的逻辑,中间合约里的batchTranfer接收ERC20代币的合约地址,代币接收方地址的数组,与之相对的token转账数量数组。在该方法对接受方数组里的每个接受者,外部调用Token合约(address B)上的transfer函数, 实现代码:_token.call(bytes4(keccak256("transfer(address,uint256)"), receivers[index], amounts[index])。这里使用到了call,这是solidity中很底层的一个方法,使用不当会出现安全隐患,请先去足够了解有关call的更多细节!
需要注意的是,在中间合约(address A)外部调用Token合约(address B)的transfer时,由于call的特性,msg.sender会从起始的address C 变成 address A,其实是从address A中转出Token,那就要先往address A中转入足够的Token。为了保障中间合约(address A)上Token的安全,合约必须要设置一个owner,batchTransfer内一定要有检查address C调用者是否与owner一致的逻辑。owner为address C, 只有address C能调用这个中间合约,且整个交易的手续费从address C扣除。
实现时还需要具体考虑安全和性能的问题,比如_receivers数组和_amounts数组的长度是否相等,循环中call调用失败后应该回退整个批量交易,避免传入的数组长度过大等等。
来看看该方案实际的效果:
下图是从某地址去调用Token合约内transfer方法的消耗:
下图是我们上面实现的,通过BatchTransfer Caller这个中间合约去调用Token合约的transfer,但这里我们先只往参数数组里传入1个接收者:
可以看到因为多了一些中间操作,参数结构也更加复杂,当传入数组长度为1时,这样比直接调用Token的transfer的Gas的消耗反而增加了一些。那我们再看看往数组里塞入10个接受者时的情况:
此时,与直接去调用Token的transfer方法10次的消耗量相比,通过BatchTransfer Caller合约来实现批量转账节省了大概30%的Gas总消耗,是不是非常可观呢。
总结
在ETH网络拥堵时,如果要批量发起交易,首先要考虑操作的安全问题,在交易确认速度和Gas Price中寻找一个平衡点,然后通过一些动态的调整机制以及合约的外部调用来保障交易的确认速度以及减少