Solidity 0.8.24 支持即将到来的 Cancun 硬分叉中包含的操作码,尤其是根据 EIP-1153 的瞬时存储操作码TSTORE 和 TLOAD。
瞬时存储是 EVM 层面上期待已久的功能,它引入了除 内存、存储、调用数据(以及返回数据和代码)之外的另一个数据位置。新的数据位置的行为类似于一个键值存储,类似于存储,主要区别在于瞬时存储中的数据不是永久性的,而是仅作用于当前交易,之后将重置为零。因此,瞬时存储与热存储访问一样便宜,TSTORE 和 TLOAD 的价格为 100 gas。
用户需要注意的是,编译器目前还不允许在高级 Solidity 代码中使用 瞬时 作为数据位置。目前,存储在此位置的数据只能通过内联汇编中的 TSTORE 和 TLOAD 操作码访问。
瞬时存储的预期典型用例是更便宜的重入锁,这些锁可以像下面展示的那样使用操作码轻松实现。但是,鉴于 EIP-1153 规范中提到的注意事项,在使用更高级的瞬时存储用例时,必须格外小心,以保持智能合约的可组合性。为了提高对此问题的认识,目前,编译器将在汇编中使用 tstore 时发出警告。
将瞬时存储用于重入锁
重入攻击利用智能合约中的一个漏洞,该漏洞允许攻击者在余额相应更新之前反复进入受害者合约,从而耗尽受害者合约的资源。在实践中,攻击者合约向受害者合约存入资金,然后发出提款调用。但是,攻击者合约没有实现 receive 函数,这会导致调用其 fallback 函数。在 fallback 中,攻击者将再次向受害者合约发出提款调用,这将导致该过程重复,直到没有更多资金可提款。这是一个已知的安全问题,是智能合约中各种漏洞的根源。为了防止被利用,建议在调用外部合约之前进行所有状态更改,例如更新帐户余额。另一个选择是使用重入锁/保护。
以下示例说明了使用瞬时存储实现的简单重入锁
contract Generosity { mapping(address => bool) sentGifts; modifier nonreentrant { assembly { if tload(0) { revert(0, 0) } tstore(0, 1) } _; // Unlocks the guard, making the pattern composable. // After the function exits, it can be called again, even in the same transaction. assembly { tstore(0, 0) } } function claimGift() nonreentrant public { require(address(this).balance >= 1 ether); require(!sentGifts[msg.sender]); (bool success, ) = msg.sender.call{value: 1 ether}(""); require(success); // In a reentrant function, doing this last would open up the vulnerability sentGifts[msg.sender] = true; } }
由于 nonreentrant 保护,无法对 claimGift 进行重入调用。在引入瞬时存储之前,已经可以使用普通存储来实现此类保护,但是高昂的成本令人却步。
对于复杂的合约,上面的简单锁可能不够,需要更复杂的設計模式。让我们考虑一个例子,其中一组函数操作两个共享的数据结构,同时执行可能导致重入尝试的调用。对每个缓冲区的访问不会相互干扰,并且可以用单独的锁来覆盖,而访问相同缓冲区的函数需要共享一个锁来确保原子访问。
contract DoubleBufferContract { uint[] bufferA; uint[] bufferB; modifier nonreentrant(bytes32 key) { assembly { if tload(key) { revert(0, 0) } tstore(key, 1) } _; assembly { tstore(key, 0) } } bytes32 constant A_LOCK = keccak256("a"); bytes32 constant B_LOCK = keccak256("b"); function pushA() nonreentrant(A_LOCK) public payable { bufferA.push(msg.value); } function popA() nonreentrant(A_LOCK) public { require(bufferA.length > 0); (bool success, ) = msg.sender.call{value: bufferA[bufferA.length - 1]}(""); require(success); bufferA.pop(); } function pushB() nonreentrant(B_LOCK) public payable { bufferB.push(msg.value); } function popB() nonreentrant(B_LOCK) public { require(bufferB.length > 0); (bool success, ) = msg.sender.call{value: bufferB[bufferB.length - 1]}(""); require(success); bufferB.pop(); } }
在上面,我们依赖于瞬时存储作为键值存储的实现(因此,允许以相同成本随机访问任何插槽)来创建两个独立的锁,它们不会相互干扰。
两个部分中都不可能出现重入调用。即,在 popA() 中触发的外部调用最终可能会进入 pushB() 或 popB()(这是完全安全的),但不会进入 pushA()。
智能合约的可组合性和瞬时存储的危险
可组合性 是软件开发中的一项基本设计原则,特别是 适用于智能合约。如果设计由可以组合在一起(“组合”)以形成更复杂应用程序的模块化组件组成,而每个组件都是一个独立的事务,与之前的组件不共享状态(除了全局状态,为了保持可组合性,每个组件应该原子地修改它),那么该设计就是可组合的。
对于智能合约来说,重要的是它们的行为以这种方式独立完成,这样,多个对单个智能合约的调用就可以组合成更复杂的应用程序。到目前为止,EVM 在很大程度上保证了可组合行为,因为在复杂事务中对智能合约的多次调用与跨多个事务对合约的多次调用实际上是无法区分的。但是,瞬时存储允许违反此原则,不正确的使用可能会导致复杂的错误,这些错误只有在跨多个调用使用时才会出现。
让我们用一个简单的例子来说明这个问题
contract MulService { function setMultiplier(uint multiplier) external { assembly { tstore(0, multiplier) } } function getMultiplier() private view returns (uint multiplier) { assembly { multiplier := tload(0) } } function multiply(uint value) external view returns (uint) { return value * getMultiplier(); } }
以及一系列外部调用
setMultiplier(42); multiply(1); multiply(2);
如果示例使用内存或存储来存储乘数,那么它将完全可组合。无论将序列拆分为单独的事务还是以某种方式将其组合在一起,都不会有任何区别。你将始终获得相同的结果。这使你可以使用用例,例如将来自多个事务的调用批处理在一起以降低 gas 成本。瞬时存储可能会破坏此类用例,因为可组合性不再是理所当然的。
但是,请注意,可组合性的缺乏不是瞬时存储的固有特性。如果重置其内容的规则稍微调整一下,就可以保留可组合性。目前,清除是针对所有合约同时进行的,在事务结束时。如果改为在没有属于它的函数在调用堆栈中处于活动状态时(这可能意味着每个事务中有多次重置)清除合约的瞬时存储,则问题将会消失。在上面的示例中,这意味着在每次调用之后清除瞬时存储。
另一个例子是,由于瞬时存储被构建为一个相对便宜的键值存储,因此智能合约作者可能会想要使用瞬时存储来替换内存中的映射,而不跟踪映射中已修改的键,从而在调用结束时不清除映射。但是,这很容易导致复杂事务中的意外行为,其中由相同事务中先前对合约的调用设置的值仍然存在。
我们建议通常始终在对智能合约的调用结束时完全清除瞬时存储,以避免此类问题,并简化对复杂事务中合约行为的分析。事实上,Solidity 团队一直在倡导更改瞬时存储规范,将其范围更改为事务中对智能合约的外部调用框架,以避免这种 EVM 层面的陷阱 - 但是,最终该担忧被忽略了,因此,负责和安全的瞬时存储使用现在由用户负责。我们仍在研究我们选择,以在基于瞬时存储操作码的基本功能之上构建的未来高级语言结构中缓解这些陷阱。
在对合约的调用框架结束时清除的重入锁,使用瞬时存储是安全的。但是,要抵制节省用于重置重入锁的 100 gas 的诱惑,因为不这样做会将合约限制在事务中的一次调用,从而阻止其在复杂的可组合事务中使用,而复杂的可组合事务一直是链上复杂应用程序的基石。