Alert Source Discuss
🚧 Stagnant Standards Track: Core

EIP-6800: 使用统一 Verkle 树的以太坊状态

这引入了一个新的 Verkle 状态树,与现有的 MPT 并行。

Authors Vitalik Buterin (@vbuterin), Dankrad Feist (@dankrad), Kevaundray Wedderburn (@kevaundray), Guillaume Ballet (@gballet), Piper Merriam (@pipermerriam), Gottfried Herold (@GottfriedHerold), Ignacio Hagopian (@jsign), Tanishq Jasoria (@tanishqjasoria), Gajinder Singh (@g11tech), Danno Ferrin (@shemnon)
Created 2023-03-17
Discussion Link https://ethereum-magicians.org/t/proposed-verkle-tree-scheme-for-ethereum-state/5805
Requires EIP-6780

摘要

引入一个新的 Verkle 状态树,与现有的十六进制 Patricia 树并行。在硬分叉之后,Verkle 树存储对状态的所有编辑和所有访问的状态的副本,并且十六进制 Patricia 树不能再被修改。这是以太坊完全依赖 Verkle 树来存储执行状态的多阶段过渡的第一步。

动机

Verkle 树解决了以太坊成为对无状态客户端友好的一个关键问题:见证大小。在今天十六进制 Patricia 树中访问帐户的见证,平均情况下接近 3 kB,在最坏的情况下可能大三倍。假设每个区块最坏情况下有 6000 次访问(15m gas / 每次访问 2500 gas),这对应于约 18 MB 的见证大小,这对于在 12 秒的时间段内通过 p2p 网络安全广播来说太大了。Verkle 树将平均情况下每个帐户的见证大小减少到约 200 字节,从而允许无状态客户端见证变得可接受地小。

规范

本文档中使用的关键词“MUST”,“MUST NOT”,“REQUIRED”,“SHALL”,“SHALL NOT”,“SHOULD”,“SHOULD NOT”,“RECOMMENDED”,“NOT RECOMMENDED”,“MAY”和“OPTIONAL”应按照 RFC 2119RFC 8174 中的描述进行解释。

Verkle 树定义

我们在此通过提供函数来定义 Verkle 树,该函数用于计算给定一组 32 字节的键和 32 字节的值的根承诺。更新和插入值的算法由实现者决定;唯一的要求是更新后的根承诺必须继续与从此规范计算出的值匹配。然后,我们将定义一个嵌入,该嵌入提供一个 32 字节的键,在该键处应存储任何特定的状态信息(帐户标头、代码、存储)。

# Bandersnatch 曲线阶数
BANDERSNATCH_MODULUS = \
13108968793781547619861935127046491459309155893440570251786403306729687672801
# 长度为 256 的 Bandersnatch Pedersen 基
PEDERSEN_BASIS = [....]
VERKLE_NODE_WIDTH = len(PEDERSEN_BASIS)

def group_to_scalar_field(point: Point) -> int:
    # 不是抗碰撞的。不是随机预言机。
    # Pedersen 承诺的绑定。
    assert isinstance(point, Point)
    if point == bandersnatch.Z:
        return 0
    else:
        return point.map_to_base_field() % BANDERSNATCH_MODULUS
    
def compute_commitment_root(children: Sequence[int]) -> Point:
    o = bandersnatch.Z
    for generator, child in zip(PEDERSEN_BASIS, children):
        o = bandersnatch.add(o, bandersnatch.mul(generator, child))
    return o

def extension_and_suffix_tree(stem: bytes31, values: Dict[byte, bytes32]) -> int:
    sub_leaves = [0] * 512
    for suffix, value in values.items():
        sub_leaves[2 * suffix] = int.from_bytes(value[:16], 'little') + 2**128
        sub_leaves[2 * suffix + 1] = int.from_bytes(value[16:], 'little')
    C1 = compute_commitment_root(sub_leaves[:256])
    C2 = compute_commitment_root(sub_leaves[256:])
    return compute_commitment_root([1, # 扩展标记
                                    int.from_bytes(stem, "little"),
                                    group_to_scalar_field(C1),
                                    group_to_scalar_field(C2)] +
                                    [0] * 252)

def compute_main_tree_root(data: Dict[bytes32, int],
                           prefix: bytes) -> int:
    # 空子树:0
    if len(data) == 0:
        return 0
    elif len(data) == 1:
        return list(data.values())[0]
    else:
        sub_commitments = [
            compute_main_tree_root({
                    key: value for key, value in data.items() if
                    key[:len(prefix) + 1] == prefix + bytes([i])
                }, prefix + bytes([i]))
            for i in range(VERKLE_NODE_WIDTH)
        ]
        return group_to_scalar_field(compute_commitment_root(sub_commitments))

def compute_verkle_root(data: Dict[bytes32, bytes32]) -> Point:
    stems = set(key[:-1] for key in data.keys())
    data_as_stems = {}
    for stem in stems:
        commitment_data = Dict[byte, bytes32]()
        for i in range(VERKLE_NODE_WIDTH):
            if stem + bytes([i]) in data:
                commitment_data[i] = data[stem + bytes([i])]
        data_as_stems[stem] = extension_and_suffix_tree(stem, commitment_data)
    sub_commitments = [
        compute_main_tree_root({
                key: value for key, value in data.items() if
                key[0] == i
            }, bytes([i]))
        for i in range(VERKLE_NODE_WIDTH)
    ]
    return compute_commitment_root(sub_commitments)

请注意,零值与位置为空不同;位置为空在底层承诺中表示为 0,但位置为零在后缀树承诺中由不同的值表示(2**128 被添加到 value_lower 以将其与空区分开)。零和空之间的这种区别不是现有 Patricia 树的属性,但它是拟议的 Verkle 树的属性。

在本文档的其余部分,在 Verkle 树中的某个位置保存或读取数字将意味着保存或读取该数字的 32 字节小端编码。

插图

这是树结构的图示。

tree structure

树嵌入

在 Verkle 树中,我们不会像 Patricia 树中那样使用双层结构,而是将所有信息嵌入到单个 key: value 树中。本节指定哪些树键存储状态中的信息(帐户标头数据、代码、存储)。

参数
BASIC_DATA_LEAF_KEY 0
CODE_HASH_LEAF_KEY 1
HEADER_STORAGE_OFFSET 64
CODE_OFFSET 128
VERKLE_NODE_WIDTH 256
MAIN_STORAGE_OFFSET 256**31

VERKLE_NODE_WIDTH > CODE_OFFSET > HEADER_STORAGE_OFFSETHEADER_STORAGE_OFFSET 大于叶键是一个必需的不变量。 此外,MAIN_STORAGE_OFFSET 必须是 VERKLE_NODE_WIDTH 的幂。

请注意,地址始终作为 Address32 传递。 要将现有地址转换为 Address32,请在前面加上 12 个零字节:

def old_style_address_to_address32(address: Address) -> Address32:
    return b'\x00' * 12 + address

标头值

这些是树中存储帐户块标头字段的位置。

def hash_point_to_bytes(point: Point) -> int:
    return group_to_scalar_field(point).to_bytes(32, 'little')

def pedersen_hash(inp: bytes) -> bytes32:
    assert len(inp) <= 255 * 16
    # 将输入解释为 128 位(16 字节)整数列表
    ext_input = inp + b"\0" * (255 * 16 - len(inp))
    ints = [2 + 256 * len(inp)] + \
           [int.from_bytes(ext_input[16 * i:16 * (i + 1)], 'little') for i in range(255)]
    return compute_commitment_root(ints).hash_point_to_bytes()

def get_tree_key(address: Address32, tree_index: int, sub_index: int):
    # 假设 VERKLE_NODE_WIDTH = 256
    return (
        pedersen_hash(address + tree_index.to_bytes(32, 'little'))[:31] +
        bytes([sub_index])
    )
    
def get_tree_key_for_basic_data(address: Address32):
    return get_tree_key(address, 0, BASIC_DATA_LEAF_KEY)

# EXTCODEHASH 的向后兼容性
def get_tree_key_for_code_hash(address: Address32):
    return get_tree_key(address, 0, CODE_HASH_LEAF_KEY)

帐户的 versionbalancenoncecode_size 字段以大端编码方式打包在 BASIC_DATA_LEAF_KEY 处找到的值中:

名字 偏移量 大小
version 0 1
code_size 5 3
nonce 8 8
balance 16 16

字节 1..4 保留供将来使用。

当前布局和编码允许将 code_size 扩展到 4 个字节,而无需更改帐户版本。

当设置任何帐户标头字段时,version 字段也会设置为零。 codehashcode_size 字段在合约或 EoA 创建时设置。

代码

def get_tree_key_for_code_chunk(address: Address32, chunk_id: int):
    return get_tree_key(
        address,
        (CODE_OFFSET + chunk_id) // VERKLE_NODE_WIDTH,
        (CODE_OFFSET + chunk_id)  % VERKLE_NODE_WIDTH
    )

i 存储一个 32 字节的值,其中字节 1…31 是代码的字节 i*31...(i+1)*31 - 1(即,它的第 i 个 31 字节切片),字节 0 是作为 PUSHDATA 一部分的引导字节数(例如,如果代码的一部分是 ...PUSH4 99 98 | 97 96 PUSH1 128 MSTORE... 其中 | 是新块开始的位置,则后一个块的编码将以 2 97 96 PUSH1 128 MSTORE 开头,以反映前 2 个字节 是 PUSHDATA)。

为了精确起见,这里是代码分块的实现:

PUSH_OFFSET = 95
PUSH1 = PUSH_OFFSET + 1
PUSH32 = PUSH_OFFSET + 32

def chunkify_code(code: bytes) -> Sequence[bytes32]:
    # 填充到 31 字节的倍数
    if len(code) % 31 != 0:
        code += b'\x00' * (31 - (len(code) % 31))
    # 确定在每个字节之后+包括多少个 pushdata
    bytes_to_exec_data = [0] * (len(code) + 32)
    pos = 0
    while pos < len(code):
        if PUSH1 <= code[pos] <= PUSH32:
            pushdata_bytes = code[pos] - PUSH_OFFSET
        else:
            pushdata_bytes = 0
        pos += 1
        for x in range(pushdata_bytes):
            bytes_to_exec_data[pos + x] = pushdata_bytes - x
        pos += pushdata_bytes
    # 输出块
    return [
        bytes([min(bytes_to_exec_data[pos], 31)]) + code[pos: pos+31]
        for pos in range(0, len(code), 31)
    ]

存储

def get_tree_key_for_storage_slot(address: Address32, storage_key: int):
    if storage_key < (CODE_OFFSET - HEADER_STORAGE_OFFSET):
        pos = HEADER_STORAGE_OFFSET + storage_key
    else:
        pos = MAIN_STORAGE_OFFSET + storage_key
    return get_tree_key(
        address,
        pos // VERKLE_NODE_WIDTH,
        pos % VERKLE_NODE_WIDTH
    )

请注意,相同大小 VERKLE_NODE_WIDTH 范围内的存储槽(即,范围形式为 x*VERKLE_NODE_WIDTH ... (x+1)*VERKLE_NODE_WIDTH-1))都属于单个承诺的一部分,但 HEADER_STORAGE_OFFSET 特殊情况除外。 这是一种优化,可以在一起访问相关存储槽时使见证更有效。 如果需要,可以将此优化暴露给 gas 计划,从而使存储相关槽的合约的 gas 效率更高(但是,Solidity 默认情况下已经以这种方式存储)。

分叉

EIP-7612 中描述。

访问事件

EIP-4762 中描述。

理由

这实现了过渡到 Verkle 树中的所有逻辑,同时改革了 gas 成本,但以最小的破坏性方式进行,不需要同时更改整个树结构。 相反,我们添加了一个新的 Verkle 树,该树最初是空的,只有对状态的新更改和访问状态的副本存储在该树中。 Patricia 树继续存在,但被冻结。

这为未来的硬分叉奠定了基础,该硬分叉会将 Patricia 树替换为存储相同数据的 Verkle 树。 与 EIP-2584 不同,此替换 Verkle 树无需由客户端实时计算。 相反,因为 Patricia 树在此时将被修复,所以替换 Verkle 树可以在链下计算。

Verkle 树设计

Verkle 树使用单层树结构,其中包含 32 字节的键和值,原因如下:

  • 简单性:使用键/值存储的抽象使编写处理树的代码(例如,数据库读取/写入、缓存、同步、证明创建和验证)以及将来将其升级到其他树变得更容易。 此外,见证 gas 规则可以变得更简单和更清晰。
  • 统一性:状态均匀地分布在整个树中; 即使单个合约有数百万个存储槽,该合约的存储槽也不会集中在一个位置。 这对于状态同步算法很有用。 此外,它有助于降低未平衡树填充攻击的有效性。
  • 可扩展性:帐户标头和代码与存储在同一结构中,可以更轻松地扩展两者的功能,甚至在以后需要时添加新结构。

Gas 改革

对读取存储和代码的 Gas 成本进行了改革,以更紧密地反映新的 Verkle 树设计下的 Gas 成本。 WITNESS_CHUNK_COST 设置为每个块收费 6.25 gas,WITNESS_BRANCH_COST 设置为平均每个字节收费 ~13,2 gas (假设分支长度为 144 字节),并且在最坏的情况下,如果攻击者填充树,则每个字节收费 ~2.5 gas,这些键是故意计算出来以最大化证明长度的。

与柏林的 gas 成本的主要区别在于:

  • 每 31 字节的代码块收费 200 gas。 据估计,这会将平均 gas 使用量增加约 6-12%
  • 访问相邻存储槽 (key1 // 256 == key2 // 256) 的成本从 2100 降至 200(对于组中第一个槽之后的所有槽),
  • 访问存储槽 0…63 的成本从 2100 降至 200,包括第一个存储槽。 这可能会显着提高许多现有合约的性能,这些合约使用这些存储槽来存储单个持久变量。

尚未分析从后两个属性中获得的收益,但很可能会显着抵消从第一个属性中获得的损失。 一旦编译器适应这些规则,效率可能会进一步提高。

访问事件发生的确切规范(构成了 gas 重新定价的大部分复杂性)对于清楚地指定何时需要将数据保存到第 1 周期树是必要的。

向后兼容性

主要的向后不兼容性更改是:

  • (1) 代码块访问的 Gas 成本使某些应用程序在经济上不那么可行
  • (2) 树结构更改使得 EVM 内的历史状态证明不再起作用

(1) 可以通过在实施此 EIP 的同时增加 gas 上限来缓解,从而降低了由于事务 gas 使用量超过区块 gas 上限而导致应用程序根本无法运行的风险。

测试用例

待定

参考实现

  • github.com/gballet/go-ethereum,分支 beverly-hills-just-after-pbss - geth 实现
  • github.com/NethermindEth/nethermind,分支 verkle/tree - nethermind 实现

安全注意事项

需要讨论。

版权

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

Citation

Please cite this document as:

Vitalik Buterin (@vbuterin), Dankrad Feist (@dankrad), Kevaundray Wedderburn (@kevaundray), Guillaume Ballet (@gballet), Piper Merriam (@pipermerriam), Gottfried Herold (@GottfriedHerold), Ignacio Hagopian (@jsign), Tanishq Jasoria (@tanishqjasoria), Gajinder Singh (@g11tech), Danno Ferrin (@shemnon), "EIP-6800: 使用统一 Verkle 树的以太坊状态 [DRAFT]," Ethereum Improvement Proposals, no. 6800, March 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6800.