Alert Source Discuss
Standards Track: Core

EIP-1153: 瞬时存储操作码

添加用于操作状态的操作码,其行为几乎与存储相同,但在每次交易后会被丢弃

Authors Alexey Akhunov (@AlexeyAkhunov), Moody Salem (@moodysalem)
Created 2018-06-15
Requires EIP-2200, EIP-3529

摘要

本提案引入了瞬时存储操作码,用于操作行为与存储相同的状态,但瞬时存储在每次交易后会被丢弃,并且 TSTORE 不受 EIP-2200 中定义的 gas 补贴检查的约束。换句话说,瞬时存储的值永远不会从存储中反序列化或序列化到存储中。因此,瞬时存储更便宜,因为它不需要磁盘访问。智能合约可以通过 2 个新的操作码访问瞬时存储,即 TLOADTSTORE,其中 “T” 代表 “瞬时:”

TLOAD  (0x5c)
TSTORE (0x5d)

动机

在以太坊中运行一笔交易会生成多个嵌套的执行帧,每个帧都由 CALL(或类似)指令创建。合约可以在同一笔交易期间被重新进入,在这种情况下,属于一个合约的帧不止一个。目前,这些帧可以通过两种方式进行通信:通过经由 CALL 指令传递的输入/输出,以及通过存储更新。如果存在属于另一个不受信任合约的中间帧,则通过输入/输出进行的通信是不安全的。一个值得注意的例子是重入锁,它不能依赖中间帧来传递锁的状态。通过存储(SSTORE/SLOAD)进行的通信成本很高。瞬时存储是解决帧间通信问题的一种专用且 gas 高效的解决方案。

由于 EIP-3529(在伦敦硬分叉中引入),由于帧间通信而累积的 Gas 退款也限制为交易花费的 gas 的 20%。这大大减少了在其他低成本交易中瞬时设置的存储槽的退款。例如,为了获得一个重入锁的全部退款,交易必须在其他操作上花费约 8 万 gas。

可以在相对简单的方式下添加语言支持。例如,在 Solidity 中,可以引入一个限定符 transient(类似于现有的限定符 memorystorage,以及 Java 具有类似含义的 transient 关键字)。由于 TSTORETLOAD 的寻址方案与 SSTORESLOAD 相同,因此用于存储变量的代码生成例程可以很容易地推广到也支持瞬时存储。

此 EIP 启用或改进的潜在用例包括:

  1. 重入锁
  2. 链上可计算的 CREATE2 地址:构造函数参数从工厂合约读取,而不是作为初始化代码哈希的一部分传递
  3. 单笔交易 ERC-20 批准,例如 temporaryApprove(address spender, uint256 amount)
  4. 转移手续费合约:向 Token 合约支付手续费,以在交易期间解锁转移
  5. “Till” 模式:允许用户执行所有操作作为回调的一部分,并在最后检查 “till” 是否平衡
  6. 代理调用元数据:将其他元数据传递到实现合约,而无需使用 calldata,例如不可变代理构造函数参数的值

这些操作码比 SSTORESLOAD 操作码执行效率更高,因为原始值永远不需要从存储中加载(即始终为 0)。Gas 记账规则也更简单,因为不需要退款。

规范

向 EVM 添加了两个新的操作码,TLOAD (0x5c) 和 TSTORE (0x5d)。(请注意,本 EIP 之前的草案指定 TLOADTSTORE 的值分别为 0xb30xb4,以避免与其他 EIP 冲突。此后已消除冲突。)

它们在堆栈上使用与 SLOAD (0x54) 和 SSTORE (0x55) 相同的参数。

TLOAD 从堆栈顶部弹出一个 32 字节的字,将此值视为地址,从该地址的瞬时存储中获取 32 字节的字,并将该值推送到堆栈顶部。

TSTORE 从堆栈顶部弹出两个 32 字节的字。顶部的字是地址,下一个是值。TSTORE 将该值保存在瞬时存储中给定的地址。

寻址方式与 SLOADSSTORE 相同。即,每个 32 字节的地址指向一个唯一的 32 字节的字。

TSTORE 的 Gas 成本与脏槽的暖 SSTORE 相同(即,原始值不是新值也不是当前值,目前为 100 gas),并且 TLOAD 的 gas 成本与热 SLOAD 相同(值之前已读取,目前为 100 gas)。由于瞬时存储与回滚的交互,Gas 成本不能与内存访问的成本相当。

瞬时存储中的所有值在交易结束时都会被丢弃。

瞬时存储对其拥有的合约是私有的,与持久存储的方式相同。只有拥有合约的帧才能访问其瞬时存储。当他们这样做时,所有帧都访问相同的瞬时存储,与持久存储的方式相同,但与内存不同。

当瞬时存储在 DELEGATECALLCALLCODE 的上下文中使用时,瞬时存储的拥有合约是发出 DELEGATECALLCALLCODE 指令的合约(调用者),与持久存储相同。当瞬时存储在 CALLSTATICCALL 的上下文中使用时,瞬时存储的拥有合约是 CALLSTATICCALL 指令的目标合约(被调用者)。

如果一个帧回滚,则在该帧进入和返回之间发生的所有对瞬时存储的写入都会被回滚,包括在内部调用中发生的写入。这模拟了持久存储的行为。

如果在 STATICCALL 的上下文中调用 TSTORE 操作码,则会引发异常而不是执行修改。允许在 STATICCALL 的上下文中执行 TLOAD

瞬时存储操作码的行为与存储操作码的不同之处在于,TSTORE 不需要根据 EIP-2200 定义的 gasleft 小于或等于 gas 补贴(目前为 2,300)。

原理

解决帧间通信问题的另一种选择是重新定价 SSTORESLOAD 操作码,使其在瞬时存储用例中更便宜。这已经在 EIP-2200 中完成。但是,EIP-3529 将最大退款减少到仅占交易 gas 成本的 20%,这意味着瞬时存储的使用受到严重限制。

另一种方法是将瞬时存储的退款计数器与用于其他存储用途的退款计数器分开,并取消瞬时存储的退款上限。但是,该方法在实现和理解上更加复杂。例如,20% 的退款上限必须应用于_在_减去无上限 gas 退款_之后_使用的 gas。否则,受 20% 退款上限约束的可用退款金额可能会因执行瞬时存储写入而增加。因此,最好有一种不与退款计数器交互的单独机制。未来的硬分叉可以删除用于支持瞬时存储用例的复杂退款行为,从而鼓励迁移到对以太坊客户端执行效率更高的合约。

对于 TSTORETLOAD 操作码的类存储的按字寻址接口,存在一个已知的异议,因为瞬时存储在生命周期中更类似于内存而不是存储。一种类似字节寻址的内存接口是另一种选择。由于映射与事务范围内存区域结合使用的有用性,因此首选类存储的按字寻址接口。通常,您需要使用任意键保持瞬时状态,例如在 ERC-20 临时批准用例中,该用例使用 (owner, spender)allowance 的映射。使用线性内存很难实现映射,并且线性内存还必须具有动态 gas 成本。使用线性内存处理回滚也更复杂。可以在底层实现使用映射来允许在任意偏移量中进行存储的同时拥有类似内存的接口,但这将导致第三种内存-存储混合接口,这需要在编译器中添加新的代码路径。

有些人认为,唯一的交易标识符可能消除了对本 EIP 中描述的瞬时存储的需求。这是一种误解:与常规存储结合使用的交易标识符具有与推动本 EIP 相同的全部问题。这两个特性是正交的。

此瞬时存储 EIP 的相对缺点:

  • 不解决现有合约中瞬时存储的使用问题
  • 客户端中的新代码
  • Yellow Paper 的新概念(需要更新更多)

此瞬时存储 EIP 的相对优点:

  • 瞬时存储操作码在协议升级中被单独考虑,不会被意外破坏(例如 EIP-3529
  • 客户端不需要加载原始值
  • 没有用于非瞬时写入的前期 Gas 成本
  • 不更改现有操作的语义
  • 无需在使用后清除存储槽
  • 更简单的 Gas 记账规则
  • 未来的存储设计(例如 Verkle 树)不需要考虑瞬时存储退款

向后兼容性

此 EIP 需要进行硬分叉才能实现。

由于此 EIP 不会更改任何现有操作码的行为,因此它与所有现有智能合约向后兼容。

测试用例

可以在此处找到此 EIP 的测试套件。

参考实现

因为瞬时存储必须在单笔交易的上下文中以几乎与存储相同的方式运行,并且要考虑到回滚行为,因此有必要能够回滚到事务中瞬时存储的先前状态。与此同时,回滚是特殊情况,加载、存储和返回应该是廉价的。

建议使用当前状态的映射加上所有更改的日志和检查点列表。这具有以下时间复杂度:

  • 在进入调用帧时,将调用标记添加到列表中 - O(1)
  • 将新值写入当前状态,并将先前的值写入日志 - O(1)
  • 当调用成功退出时,会丢弃该调用进入时的日志索引标记 - O(1)
  • 在恢复时,所有条目都会恢复到最后一个检查点,以相反的顺序恢复 - O(N),其中 N = 自上次检查点以来的日志条目数
interface JournalEntry {
    addr: string
    key: string
    prevValue: string
}

type Journal = JournalEntry[]

type Checkpoints = Journal['length'][]

interface Current {
    [addr: string]: {
        [key: string]: string
    }
}

const EMPTY_VALUE = '0x0000000000000000000000000000000000000000000000000000000000000000'

class TransientStorage {
    /**
     * 瞬时存储的当前状态。
     */
    private current: Current = {}
    /**
     * 所有更改都将写入日志中。回滚时,我们在相反的方向上将更改应用于最后一个检查点。
     */
    private journal: Journal = []
    /**
     * 每个检查点的时间日志长度
     */
    private checkpoints: Checkpoints = [0]

    /**
     * 返回给定合约地址和键的当前值
     * @param addr 合约地址
     * @param key 地址的瞬时存储的键
     */
    public get(addr: string, key: string): string {
        return this.current[addr]?.[key] ?? EMPTY_VALUE
    }

    /**
     * 在映射中设置当前值
     * @param addr 要设置的合约的地址
     * @param key 要为地址设置的槽
     * @param value 要设置的槽的新值
     */
    public put(addr: string, key: string, value: string) {
        this.journal.push({
            addr,
            key,
            prevValue: this.get(addr, key),
        })

        this.current[addr] = this.current[addr] ?? {}
        this.current[addr][key] = value;
    }

    /**
     * 提交自上次检查点以来的所有更改
     */
    public commit(): void {
        if (this.checkpoints.length === 0) throw new Error('Nothing to commit')
        this.checkpoints.pop() // 最后一个检查点被删除。
    }

    /**
     * 每当进入新上下文时调用。如果在检查点后调用 revert,则恢复最新检查点后所做的所有更改。
     */
    public checkpoint(): void {
        this.checkpoints.push(this.journal.length)
    }

    /**
     * 将瞬时存储恢复到上次调用检查点时的状态
     */
    public revert() {
        const lastCheckpoint = this.checkpoints.pop()
        if (typeof lastCheckpoint === 'undefined') throw new Error('Nothing to revert')

        for (let i = this.journal.length - 1; i >= lastCheckpoint; i--) {
            const {addr, key, prevValue} = this.journal[i]
            // 我们可以假设它存在,因为它写在日志中
            this.current[addr][key] = prevValue
        }
        this.journal.splice(lastCheckpoint, this.journal.length - lastCheckpoint)
    }
}

最坏情况的时空复杂度可能是通过写入可以在一个区块中容纳的最大数量的键,然后回滚来产生的。在这种情况下,客户端需要执行两倍的写入操作才能应用日志中的所有条目。但是,同样的情况也适用于现有客户端的状态日志记录实现,并且无法使用以下代码进行 DOS:

pragma solidity =0.8.13;

contract TryDOS {
    uint256 slot;

    constructor() {
        slot = 1;
    }

    function tryDOS() external {
        uint256 i = 1;
        while (gasleft() > 5000) {
            unchecked {
                slot = i++;
            }
        }
        revert();
    }
}

安全考虑事项

TSTORE 提供了一种以线性成本在节点上分配内存的新方法。换句话说,每个 TSTORE 允许开发人员以 100 gas 的成本存储 32 字节,不包括准备堆栈所需的任何其他操作。给定 3000 万 gas,可以使用 TSTORE 分配的最大内存量为:

30M gas * 1 TSTORE / 100 gas * 32 bytes / 1 TSTORE * 1MB / 2^20 bytes ~= 9.15MB

给定相同数量的 gas,可以在单个上下文中通过 MSTORE 分配的最大内存量约为 3.75MB:

30M gas = 3x + x^2 / 512 => x = ~123,169 32-byte words
~123,169 words * 32 bytes/word * 1MB / 2^20 bytes = 3.75MB

但是,如果您仅在每个上下文中花费 1M gas 来分配内存,并进行调用以重置内存扩展成本,则您每百万 gas 可以分配约 700KB,总共分配约 20MB 的内存:

1M gas = 3x + x^2 / 512 => x = ~21,872 32-byte words
30M gas * ~21,872 words / 1M gas * 32 bytes/word * 1MB / 2^20 bytes = ~20MB

智能合约开发人员应在使用前了解瞬时存储变量的生命周期。由于瞬时存储在交易结束时会自动清除,因此智能合约开发人员可能会试图避免清除作为调用一部分的插槽以节省 gas。但是,这可能会阻止在同一交易中与合约的进一步交互(例如,在重入锁的情况下)或导致其他错误,因此智能合约开发人员应注意_仅_在这些插槽旨在供同一交易中的未来调用使用时,才将瞬时存储插槽保留为非零值。否则,这些操作码的行为与 SSTORESLOAD 完全相同,因此所有常见的安全注意事项都适用,尤其是在重入风险方面。

智能合约开发人员也可能倾向于使用瞬时存储来代替内存映射。他们应该意识到,当调用返回或回滚时,瞬时存储不会像内存那样被丢弃,并且应该更喜欢这些用例的内存,以免在同一交易中重新进入时产生意外行为。瞬时存储比内存必然高昂的成本应该已经阻止了这种使用模式。大多数内存映射的使用都可以通过键排序的条目列表得到更好的实现,并且智能合约中很少需要内存映射(即作者不知道生产中是否有已知的用例)。

版权

通过 CC0 放弃版权及相关权利。

Citation

Please cite this document as:

Alexey Akhunov (@AlexeyAkhunov), Moody Salem (@moodysalem), "EIP-1153: 瞬时存储操作码," Ethereum Improvement Proposals, no. 1153, June 2018. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-1153.