一文澄清以太坊开发者常见误解:Gas、交易与智能合约等
撰文:spalladino
翻译 & 校对:闵敏 & 阿剑
最近,我偶然读到了一篇题为《程序员关于时区的误解》的文章,让我爆笑不已。这篇文章让我想到了程序员在其它方面的误解,如人名和时间,于是我开始寻找有没有关于以太坊的。奈何寻觅无果,我只得尽自己的绵薄之力。
关于 Gas 的误解
调用 estimateGas 会返回交易所需消耗的 gas 量
调用 estimateGas 确实会返回一个 gas 耗费量,但这是该笔交易在当前状态下被打包会花费的 gas 量。而区块链的当前状态可能与你需要该笔交易上链时的状态大相径庭。因此,当你的交易被有效打包进区块时,可能会采用不同的代码路径,需要消耗的 gas 量也有可能完全不同。
如果执行的代码相同,我的交易所需消耗的 gas 量也相同。
不对。即使你使用相同的参数来执行相同的指令,gas 成本也有可能不同。例如,相比已经有非零值的存储位置,如果你要写入新的存储位置,SSTORE (写入存储操作)的成本会高得多(参见 EIP2200)。这就意味着,如果你向一个新地址发送两笔 ERC20 代币转账,第一笔交易的成本会比第二笔高得多,即使二者执行的代码完全相同。
如果状态完全相同,我的交易所需消耗的 gas 量也相同
通常情况下是的,除非你很倒霉地碰上了硬分叉,导致一些操作重新定价。虽然这听起来很复杂,但说白了就是,你无法针对 dApp 中交易的 gas 上限进行安全的硬编码,除非你决心在每次发生硬分叉后都发布 dApp 更新。
如果代码相同,状态也相同,且没有发生硬分叉,我就可以相信
estimateGas
的返回值了吗?
这下你可以相信 estimateGas 的返回值就是你的交易所需消耗的 gas 量了,但是你不知道这笔交易是否会如你所愿的那般进行。所谓的 gas 估测,就是节点将使用不同的 gas 值来尝试你的交易,并返回确保你的交易不会失败的最低 gas 值。但是,节点只会看你的交易,不会看交易的内部调用。这就意味着,如果你调用的合约代码有一个 try/catch 块,导致内部调用发生后无法撤销,你获得的 gas 估测值对调用合约来说是够用的,但是对被调用合约来说就不够了。
在多签名钱包中,这种情况经常发生:即使是在交易失败的情况下,大多数多签钱包会将操作标记为已执行,也就是说它们无法撤销最外层的交易(所带来的影响)。因此,一个原生的 gas 估测返回的值可能对多签代码来说是足够的,对你实际想运行的操作来说不一定足够。这就是为什么 Gnosis Safe 有一个专门的 gas 估测方法。
请注意,这也就是为什么因为 gas 不够而导致操作失败的情况很难察觉。内部调用可能会因为被分配到的 gas 太少而将 gas 耗尽,而交易本身可能还有很多 gas 可用。这就意味着,查看交易的 gas 使用量和 gas 上限并非检测 gas 错误的可靠方式。
管他呢,我每次多发送点 gas 就好了
多数情况下,这个方法是管用的。但是请记住,合约是可以查看它在一笔交易中收到的 gas 的。因此,可以轻而易举地将合约编写成,一旦收到过多 gas,交易就会失败。不过我怀疑的是,除了证明这一点外,这么做没有任何意义。
关于交易的误解
只要节点接受了交易,交易就会被挖出
想得美哦。以太坊的网络拥堵会导致 gas 价格波动很大,因此你的交易可能会被逐出 mempool (等待被挖出的交易集合)。如果 gas 价格飙升,你就需要重新发送交易。
我可以略微提高 gas 价格然后重新提交交易
只要你将 gas 价格提高到与你交互的节点所需的最小量(参见
txpool.pricebump
),那就没什么问题,否则还是会被拒绝。
矿工总选择 gas 价格最高的交易
并不一定。矿工可以随心所欲进行选择。他们可能会为了自己的利益而塞入自己的交易,甚至可以开一个协议外通道,为符合自己要求的用户打包交易。
但是,即使他们依据收益来决定打包优先级,如何以最优方式填满区块也是一个背包问题(knapsack problem)。由于交易无法被分割成几部分,所以,在 gas 上限为 10M 的区块中打包两个 5M gas 交易,而不是一个 6M gas 的交易,可能更为有利可图,即使 5M gas 交易的 gas 价格低于 6M gas 交易。
如果我以更高的 gas 价格发送相同的交易,矿工会选择后一个交易来替换前一个交易吗
替换交易必须在旧交易上链之前发送到矿工那里。也就是说,如果你发送了替换交易,你依然需要监控你之前发送的同一个 nonce 下的所有交易的哈希值。
关于 Nonce 的误解
我可以通过
getTransactionCount
得到我的下一笔交易的 nonce
这取决于你所使用的区块参数。如果你根据最新区块来查询你的交易记数,就会忽略你的未打包交易,并进一步导致你不小心覆盖你的某笔未打包交易。
我可以通过
getTransactionCount('pending')
得到我的下一笔交易的 nonce
虽然这在大多数情况下可行,但是你不能保证你的所有未打包交易都在你所查询的节点的 mempool 中。如果你有很多未打包交易,你所通信的节点可能已经丢弃了其中一些交易,但是这些交易仍有可能存在于其它地方!
关于 Log 的误解
我可以通过持续调用
getLogs
来有效监控事件
尽管这是一个非常管用的方法(没错,说的就是轮询!),但是遇上链重组就会出问题。如果你要轮询最新区块上的新 log,你不会收到关于区块重组的通知,也不知道你所看到的事件是否需要重新调整。
我可以通过安装过滤程序来有效监控事件
直到两周前,这还不是一种常见选择,因为 Infura 不支持基于 http 的过滤程序,MetaMask 默认使用基于 http 的过滤程序,也就是说你的 dApp 有 99% 的用户都使用这种过滤程序(注:我可能有些夸大)。除了新事件之外,过滤程序还会通知你因区块重组而删除的事件。但是,这就要求你正在与之交互的基础设施和节点保持在线。如果它们碰巧丢失了过滤程序的状态,你就有可能错过重组事件。
我可以通过 websocket 订阅来有效监控事件
太好了!这样下来,除了要相信你的节点会保持在线之外,你还要相信你本人会保持在线,你和节点之间的连接是可靠的。我想知道这周你在参加 Zoom 会议时掉线了几次?
现在,我必须承认,我已经对这个话题有点着迷了,以至于我在 Devcon 5 上就此进行了一场闪电演讲。如果你想了解更多内容,EIP234 很好地阐述了这些挑战的基本原理,
ethereumjs-blockstream
则解决了这一问题。
关于合约的误解
智能合约是不可更改的
兄弟,如果你还有这种想法,你真的 out 了。我在一篇长达 30 页的文章中阐述过这一点,真的非常长。
不包含任何
DELEGATECALL
的智能合约就是不可更改的
实际上,合约可以定期调用(
CALL
)到一个可变地址中,并将结果作为计算的一部分,或者作为更改状态的指令,从而更改正在运行的代码。
那不包含任何
DELEGATECALL
或
CALL
的智能合约,总是不可更改的了吧?
还有
STATICCALL
。别忘了
STATICCALL
!
不包含任何
CALL
的智能合约是不可更改的
你还得排除一种情况:这个智能合约是通过 CREATE2 部署的,会在其初始码(initcode)中动态载入运行时,并且可以自毁。在这种情况下,“所有者” 可以销毁合约,并使用不同的代码在同一个地址上重新创建这个合约。
不包含任何
CALL
且不通过
CREATE2
部署的智能合约是不可更改的
还得排除一种情况:这个合约是通过由 CREATE2 部署的合约部署的。因此,你需要追溯整个部署链条,找到最初创建合约的以太坊外部账户,确保没有任何猫腻,而且不存在自毁操作。这篇文章深入探究了这一问题。
关于 ERC20 代币的误解
我就不展开了,这个话题更适合写成一篇完整的文章。在与代币交互时,使用 OpenZeppelin 的 SafeERC20 (你可以在这篇文章中阅读更多相关内容)就好。请记住,在转账时,接收者所收到的代币并不一定等于发送者被扣除的代币。我们来看下一部分吧。
关于以太币的误解
以太币的总供应量只会增加
我们都知道,有很多以太币是无法使用的,有的是因为外部账户的私钥丢失,有的是因为意外发送到全零地址,还有的是因为被卡在合约中无法处理(对不起,我没忍住)。总而言之,这部分以太币依然存在,但是无法访问。
不过,有一种方法可以销毁以太币。如果你指令一个合约自毁
selfdestruct
并指定其自身作为资金的接收方,这个合约内的所有以太币都将被销毁。这就意味着,只要愿意销毁比区块奖励更多的以太币,就可以让以太币通缩。
我可以写一个能拒绝任何以太币转入的合约
你或许知道,如果你没有声明任何
payable
方法,Solidity 会拒绝所有发送到你的合约的以太币转账,防止资金被卡在合约内。但是,我们也可以在不触发任何代码的情况下,将资金发送到合约内:要么将该合约指定为自毁操作奖励的接收方,要么将其指定为区块奖励的接收方。正如 @gorgos 在评论中指出的那样,可以预先计算出合约部署地址,并在合约部署前将以太币发送到该地址。
也就是说,如果你追踪所有发送到你的合约的以太币转账,你的总余额可能大于你处理的所有转账的总和。
来源链接: gist.github.com