使用 Solidity 瞬态存储操作码

使用 Cancun 硬分叉加入的 TSTORE 降低 Gas

Solidity 0.8.24 支持 Cancun 硬分叉(在 2024 年 3 月执行) 中包含的操作码,特别是根据 EIP-1153 的瞬态存储操作码 TSTORE 和 TLOAD。

瞬态存储是 EVM 层面期待已久的功能,它引入了除内存、存储、calldata(以及返回数据和代码)之外的另一种数据位置。新的数据位置表现为类似于存储的键值存储,主要区别在于瞬态存储中的数据不是永久的,而是仅限于当前交易,在此之后将重置为零。因此,瞬态存储的成本与热存储访问一样便宜,TSTORE 和 TLOAD 的价格为 100 gas。

小编注解: 在交易中第一次读写存储插槽时,该插槽会从“冷”变为热,读写冷存储需要消耗更多的 Gas(多2100)

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

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

使用瞬态存储进行重入锁

重入攻击利用了智能合约中的一个漏洞,即受害合约在余额相应更新之前被反复进入,从而耗尽其资源。实际上,攻击者合约将资金存入受害合约,然后发出提款调用。然而,攻击者合约没有实现接收函数,这导致其回退函数被调用。在回退函数中,攻击者将再次对受害合约进行提款调用,这将导致该过程重复进行,直到没有更多资金可提取。这是一个已知的安全问题,也是智能合约中各种错误的来源。为了防止其被利用,建议在调用外部合约之前进行所有状态更改,例如更新账户余额。另一种选择是使用重入锁/防护。

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

    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()。

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

可组合性 是软件开发中的基本设计原则, 特别适用于智能合约 。如果一个设计由可以链接在一起(“组合”)以形成更复杂应用程序的模块化组件组成,并且每个组件都是独立的交易,不与先前的组件共享状态(除了全局状态,为了保持可组合性,每个组件应原子地修改全局状态),则该设计是可组合的。

对于智能合约来说,重要的是它们的行为以这种方式是自包含的,这样对单个智能合约的多次调用可以组合成更复杂的应用程序。到目前为止,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();
    }
}

and a sequence of external calls:

setMultiplier(42);
multiply(1);
multiply(2);

如果示例使用内存或存储来存储乘数,它将是完全可组合的。无论你是将序列拆分为单独的交易,还是以某种方式将它们组合在一起,都不会有任何影响。你将始终获得相同的结果。这使得可以将多个交易的调用批处理在一起以减少 gas 成本。瞬态存储可能会破坏这种用例,因为组合性不再是理所当然的。

但请注意,缺乏组合性并不是瞬态存储的固有属性。如果重置其内容的规则稍作调整,它本可以被保留。目前,清除发生在所有合约同时进行,当交易结束时。如果改为在调用堆栈中不再有属于它的函数处于活动状态时(这可能意味着每个交易多次重置)清除合约的瞬态存储,问题将消失。在上面的示例中,这意味着在每次调用后清除瞬态存储。

另一个例子是,由于瞬态存储被构造成相对便宜的键值存储,智能合约作者可能会倾向于使用瞬态存储作为内存映射的替代品,而不跟踪映射中修改的键,从而在调用结束时不清除映射。然而,这很容易导致复杂交易中的意外行为,其中在同一交易中对合约的先前调用设置的值仍然存在。

我们建议通常在调用智能合约结束时始终完全清除瞬态存储,以避免此类问题,并简化对复杂交易中合约行为的分析。事实上,Solidity 团队一直在倡导更改瞬态存储的规范,将其范围更改为交易中智能合约的最外层调用框架,以避免这种在 EVM 级别的陷阱——然而,这一担忧最终被忽略了,因此,瞬态存储的负责任和安全使用现在由用户负责。我们仍在研究我们的选项,以在未来基于瞬态存储操作码的高级语言构造中减轻这些陷阱。

用于在调用框架结束时清除的重入锁的瞬态存储是安全的。然而,请务必抵制节省重置重入锁所用的 100 gas 的诱惑,因为如果不这样做,将限制你的合约在一个交易中只能进行一次调用,从而阻止其在复杂组合交易中的使用,而复杂组合交易一直是链上复杂应用的基石。

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 2
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO