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 2119 和 RFC 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 字节小端编码。
插图
这是树结构的图示。
树嵌入
在 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_OFFSET
且 HEADER_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)
帐户的 version
、balance
、nonce
和 code_size
字段以大端编码方式打包在 BASIC_DATA_LEAF_KEY
处找到的值中:
名字 | 偏移量 | 大小 |
---|---|---|
version |
0 | 1 |
code_size |
5 | 3 |
nonce |
8 | 8 |
balance |
16 | 16 |
字节 1..4
保留供将来使用。
当前布局和编码允许将 code_size
扩展到 4 个字节,而无需更改帐户版本。
当设置任何帐户标头字段时,version
字段也会设置为零。 codehash
和 code_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.