Solidity 0.8.24 中的瞬态存储操作码

Solidity 0.8.24 版本支持了 Cancun 硬分叉中包含的操作码,特别是 EIP-1153 提案中的瞬态存储操作码 TSTORE 和 TLOAD。瞬态存储类似于存储,但数据不是永久性的,仅限于当前交易,之后会被重置为零。文章还讨论了瞬态存储的用例、注意事项及其对智能合约可组合性的潜在影响。

Solidity 0.8.24 支持即将到来的 Cancun 硬分叉中包含的操作码,特别是根据 EIP-1153 的瞬态存储操作码 TSTORE 和 TLOAD。

瞬态存储是 EVM 级别上一个期待已久的功能,它引入了除内存、存储、calldata(以及 return-data 和 code)之外的另一种数据位置。 新的数据位置的行为类似于存储,作为键值存储,主要区别在于瞬态存储中的数据不是永久的,而是仅限于当前事务,之后它将被重置为零。 因此,瞬态存储与热存储访问一样便宜,TSTORE 和 TLOAD 的价格为 100 gas。

用户应注意,编译器还不允许在高层 Solidity 代码中使用 transient 作为数据位置。 目前,存储在此位置的数据只能使用内联汇编中的 TSTORE 和 TLOAD 操作码进行访问。

瞬态存储的一个预期典型用例是更便宜的重入锁,可以使用操作码轻松实现,如下所示。 但是,鉴于 EIP-1153 规范中提到的注意事项,对于瞬态存储更高级的用例,必须格外小心,以保持智能合约的可组合性。 为了提高对此问题的认识,目前,编译器将在汇编中使用 tstore 时发出警告。

使用瞬态存储进行重入锁

重入攻击利用了智能合约漏洞,其中受害者合约的资源在余额相应更新之前被重复进入而耗尽。 实际上,发生的情况是攻击者合约将资金存入受害者合约,然后发出提款调用。 但是,攻击者合约未实现 receive 函数,这会导致改为调用他的 fallback 函数。 在 fallback 中,攻击者将再次向受害者合约发出提款调用,这将导致该过程重复进行,直到没有更多资金可提取。 这是一个已知的安全问题,也是智能合约中各种 bug 的根源。 为了防止它被利用,建议在调用外部合约之前进行所有状态更改,例如更新帐户余额。 另一种选择是使用重入锁/保护。

以下示例说明了一个借助瞬态存储实现的简单重入锁:

contract Generosity {
    mapping(address => bool) sentGifts;

    modifier nonreentrant {
        assembly {
            if tload(0) { revert(0, 0) }
            tstore(0, 1)
        }
        _;
        // 解锁保护,使模式可组合。
        // 函数退出后,即使在同一事务中也可以再次调用它。
        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);

        // 在可重入函数中,最后执行此操作会打开漏洞
        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()。

智能合约的可组合性与瞬态存储的危险

可组合性 (Composability) 是软件开发中的一个基本设计原则,并且 特别适用于智能合约 (applies to smart contracts in particular)。 如果一个设计由可以链接在一起(“组合”)到更复杂应用程序的模块化组件组成,同时每个组件都是一个独立的事务,不与之前的组件共享状态(除了全局状态,为了保持可组合性,应该由每个组件原子地修改),那么这个设计就是可组合的。

对于智能合约而言,重要的是它们的行为以这种方式是独立的,这样对单个智能合约的多次调用可以组合成更复杂的应用程序。 到目前为止,EVM 在很大程度上保证了可组合的行为,因为在复杂事务中对智能合约的多次调用与在多个事务中对合约的多次调用实际上没有区别。 然而,瞬态存储允许违反此原则,不正确的使用可能导致复杂的 bug,这些 bug 仅在使用多个调用时才会出现。

让我们用一个简单的例子来说明这个问题:

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 的诱惑,因为未能这样做会将你的合约限制为事务中的一次调用,从而阻止其在复杂的组合事务中使用,而这一直是链上复杂应用程序的基石。

  • 原文链接: soliditylang.org/blog/20...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
SolidityLang
SolidityLang
https://soliditylang.org/