BiB:以太坊zkEVM数据可用性提案

Block-in-Blobs (BiB) 是一个以太坊改进提案 (EIP),旨在解决 zkEVM 时代的数据可用性问题。

PR: https://github.com/ethereum/EIPs/pull/11212

摘要

zkEVM 允许验证者使用证明来验证执行负载的正确性,而无需下载或执行负载本身。然而,取消下载执行负载的要求,也同时取消了隐式的数据可用性保证;区块生产者可以发布有效证明并隐藏执行负载数据,因为验证者不再需要它来进行共识。

本 EIP 引入了“块中数据块”(BiB)机制,该机制要求执行负载数据必须作为 blob 数据发布,且与携带相应执行负载头部的信标区块在同一区块中。这确保了即使验证者不再需要执行负载来验证状态转换函数 (STF),执行负载也始终可用。

简而言之,BiB 的工作原理是:区块生产者将执行负载数据编码为 blob,作为执行层 STF 的一部分,并要求信标区块的 blob KZG 承诺对这些负载 blob 进行承诺。

动机

通过重执行进行验证

当前,验证者通过以下方式验证执行负载:

  1. 下载执行负载
  2. 在本地执行负载
  3. 将结果状态根和其他字段与头部中的字段进行核对

这隐式地保证了执行负载的可用性,因为除非节点下载了负载,否则无法验证。

使用 zkEVM 进行验证

使用 zkEVM 时,验证者改为:

  1. 下载一个证明,证明执行负载的正确性
  2. 下载执行负载头部
  3. 根据负载头部(和其他承诺)验证证明

在这种模型中,验证者不再需要访问完整的执行负载数据本身即可验证其正确性。

数据可用性问题

在共识中取消重执行要求,同时也取消了负载必须可用的隐式要求。

恶意或理性的构建者可以:

  • 为有效的执行负载发布有效证明
  • 完全隐藏执行负载数据

构建者:由于构建者始终需要重执行以构建区块,恶意构建者不会发布执行负载,从而确保他们自己是唯一能够在当前链上构建的人。

RPC 和索引器:许多节点,如 RPC 提供者和索引器,不能完全依赖执行证明,必须重执行执行负载。

BiB 通过让执行负载以 blob 形式可用来解决此问题。

规范

定义

执行负载数据

执行负载数据指的是 ExecutionPayload 中必须通过 blob 可用的子集。包括:

  • transactions
  • withdrawals
  • requests

请参阅理性分析中的 执行负载数据包含哪些内容? 以了解为什么包含这些字段而不包含其他字段。

概述与不变量

BiB 确保已证明的负载被发布:

  • 信标区块引用一个 blob KZG 承诺列表(通过 4844/PeerDAS)
  • 这些承诺的前缀被保留用于编码为 blob 的执行负载数据
  • 该区块的 zkEVM 证明必须将已证明的执行负载与这些前缀 blob 承诺绑定。

负载可用性不变量: 一个有效区块意味着存在一个有序的 blob 列表,其字节解码为规范执行负载数据,并且这些 blob 的 KZG 承诺与区块引用的前 payload_blob_count 个 blob 承诺匹配。现有的 DAS 机制将确保这些 blob 可用。

参数

新增参数

名称 描述
MAX_PAYLOAD_BLOBS_PER_BLOCK 待定 可用于编码执行负载的最大 blob 数量

引用参数

这些参数在 EIP-4844 及相关规范中定义:

名称 来源
FIELD_ELEMENTS_PER_BLOB 4096 EIP-4844
BYTES_PER_FIELD_ELEMENT 32 EIP-4844
GAS_PER_BLOB 2**17 EIP-4844
MAX_BLOBS_PER_BLOCK 因分叉而异 PeerDAS/BPO

派生常量

名称 描述
USABLE_BYTES_PER_FIELD_ELEMENT BYTES_PER_FIELD_ELEMENT - 1 (31) 每个域元素的可使用字节数(最后一个字节必须为零,以保持在 BLS 模数以下)
USABLE_BYTES_PER_BLOB FIELD_ELEMENTS_PER_BLOB * USABLE_BYTES_PER_FIELD_ELEMENT 每个 blob 的总可使用字节数

引用的辅助函数

在本提案中,我们使用相应共识 4844/7594 规范中定义的方法和类。

具体来说,我们使用 polynomial-commitments.md 中的以下方法:

以及 beacon-chain.md 中的以下方法:

辅助函数

bytes_to_blobs

def bytes_to_blobs(data: bytes) -> List[Blob]:
    """
    将任意字节打包成一个或多个 blob。
    前 4 个字节以小端序编码原始数据的长度。
    最后一个 blob 中的剩余空间用零填充。

    注意:4 字节长度前缀(最大约 4GB)是足够的,因为假设执行负载不会那么大。
    """
    length_prefix = len(data).to_bytes(4, 'little')
    prefixed_data = length_prefix + data

    # 填充到 USABLE_BYTES_PER_BLOB 的整数倍
    padding_needed = (USABLE_BYTES_PER_BLOB - (len(prefixed_data) % USABLE_BYTES_PER_BLOB)) % USABLE_BYTES_PER_BLOB
    prefixed_data = prefixed_data + bytes(padding_needed)

    blobs = []
    offset = 0

    while offset < len(prefixed_data):
        chunk = prefixed_data[offset : offset + USABLE_BYTES_PER_BLOB]
        blob = bytes_to_blob(chunk)
        blobs.append(blob)
        offset += USABLE_BYTES_PER_BLOB

    return blobs

def bytes_to_blob(data: bytes) -> Blob:
    """
    将恰好 USABLE_BYTES_PER_BLOB 个字节打包成一个 blob。
    每个 31 字节的块存储在域元素的字节 [0:31] 中,
    字节 [31](最后一个字节)设置为零,以确保值 < BLS 模数。
    """
    assert len(data) == USABLE_BYTES_PER_BLOB

    blob = bytearray(FIELD_ELEMENTS_PER_BLOB * BYTES_PER_FIELD_ELEMENT)

    for i in range(FIELD_ELEMENTS_PER_BLOB):
        chunk_start = i * USABLE_BYTES_PER_FIELD_ELEMENT
        chunk = data[chunk_start : chunk_start + USABLE_BYTES_PER_FIELD_ELEMENT]

        # 在 [0:31] 中存储 31 个数据字节,最后一个字节 [31] 保持为零
        blob[i * 32 : i * 32 + 31] = chunk

    return Blob(blob)

blobs_to_bytes

def blobs_to_bytes(blobs: List[Blob]) -> bytes:
    """
    将 blob 解包回字节。
    读取长度前缀(小端序)以确定实际数据大小。
    """
    raw = bytearray()

    for blob in blobs:
        raw.extend(blob_to_bytes(blob))

    length = int.from_bytes(raw[0:4], 'little')
    return bytes(raw[4 : 4 + length])

def blob_to_bytes(blob: Blob) -> bytes:
    """
    从每个域元素中提取 31 个可用字节。
    验证每个域元素的最后一个字节是否为零。
    """
    result = bytearray()

    for i in range(FIELD_ELEMENTS_PER_BLOB):
        # 验证最后一个字节是否为零
        assert blob[i * 32 + 31] == 0x00, "无效的 blob:最后一个字节必须为零"

        # 提取 31 个数据字节
        result.extend(blob[i * 32 : i * 32 + 31])

    return bytes(result)

ExecutionPayloadData

TODO:未引用 MAX_TRANSACTIONS_PER_PAYLOAD、MAX_WITHDRAWALS_PER_PAYLOAD、MAX_REQUESTS_PER_PAYLOAD

class ExecutionPayloadData(Container):
    transactions: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD]
    withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD]
    requests: List[Request, MAX_REQUESTS_PER_PAYLOAD]

get_execution_payload_data

def get_execution_payload_data(payload: ExecutionPayload) -> ExecutionPayloadData:
    """
    从 ExecutionPayload 中提取必须通过 blob 可用的数据。
    """
    return ExecutionPayloadData(
        transactions=payload.transactions,
        withdrawals=payload.withdrawals,
        requests=payload.requests,
    )

execution_payload_data_to_blobs

def execution_payload_data_to_blobs(data: ExecutionPayloadData) -> List[Blob]:
    """
    将执行负载数据规范编码为一个有序的 blob 列表。

    编码步骤:
      1. payload_bytes = SSZ.serialize(data)
      2. 返回 bytes_to_blobs(payload_bytes)

    注意:这使用 SSZ 序列化 ExecutionPayloadData 容器。
    单个交易仍为不透明的字节数组(其内部的 RLP 编码保持不变,不会重新编码)。
    """
    payload_bytes = SSZ.serialize(data)
    return bytes_to_blobs(payload_bytes)

blobs_to_execution_payload_data

def blobs_to_execution_payload_data(blobs: List[Blob]) -> ExecutionPayloadData:
    """
    将一个有序的 blob 列表规范解码为执行负载数据。

    解码步骤:
      1. payload_bytes = blobs_to_bytes(blobs)
      2. 返回 SSZ.deserialize(payload_bytes, ExecutionPayloadData)
    """
    payload_bytes = blobs_to_bytes(blobs)
    return SSZ.deserialize(payload_bytes, ExecutionPayloadData)

可逆性不变量: execution_payload_data_to_blobsblobs_to_execution_payload_data 在有效的执行负载数据上是互逆的。

执行层

摘要: 执行层在两个方面进行了修改:

  • EL 头部现在有一个 payload_blob_count 字段,以便我们可以准确计算总的 blob_gas_used。我们在计算中不仅包含类型 3 交易,还包含负载 blob,以便 blob_gas_used 准确反映 CL 使用了多少个 blob。
  • engine_newPayload 接收 ExecutionPayload,并在将其传递给 EL STF 之前,计算负载 blob,检查所需的 blob 数量是否等于 ExecutionPayload 头部中的 payload_blob_count 值,并像以前一样检查预期的版本哈希是否匹配。

数据结构

本 EIP 向 ExecutionPayloadHeader 添加了一个新字段:

  • payload_blob_count : uint64

语义:

  • blob_kzg_commitments 为信标区块引用的有序 kzg 承诺列表
  • blob_kzg_commitments 的前 payload_blob_count 个条目是负载 blob 承诺(即对应负载数据的 blob 的承诺)
  • 剩余的条目(如果有)用于类型 3 blob 交易。

验证

在执行层,区块验证规则修改如下:

def validate_block(block: Block):
  blob_gas_used = block.payload_blob_count * GAS_PER_BLOB

  # ...

换句话说,不再是 blob_gas_used = 0,而是根据负载 blob 消耗的 blob 数量来初始化 blob_gas_used。单独的 EL STF 假定 payload_blob_count 是正确的,因为它无法在 validate_block 中检查。payload_blob_count 的正确性在 Engine API 边界处通过重新计算负载 blob 并检查与信标区块的 blob 承诺的一致性来强制执行。

此更改不会影响共识层的 blob 记账规则;它仅确保执行负载中的 blob_gas_used 准确反映总 blob 使用量,包括负载 blob。

Engine API

本节指定了 new_payload 的两种等效形式。实现者根据其执行上下文选择一种:

  • 原生执行变体:直接使用 blob_to_kzg_commitment。适用于预强制性证明的实现。
  • zkEVM 优化变体:使用通过 verify_blob_kzg_proof_batch 的多项式打开。避免了在 zkEVM 电路中证明代价高昂的多标量乘法 (MSM)。

两种变体都强制执行相同的有效性条件。一个变体下有效的区块在另一个变体下也有效。

原生执行变体
fn new_payload(
    payload: ExecutionPayload,
    expected_blob_versioned_hashes: List[VersionedHash],
    ...
) -> PayloadStatus:

    # 1. 导出负载 blob
    payload_data = get_execution_payload_data(payload)
    payload_blobs = execution_payload_data_to_blobs(payload_data)
    payload_blob_count = len(payload_blobs)
    payload_versioned_hashes = [blob_to_versioned_hash(b) for b in payload_blobs]

    # 2. 验证 payload_blob_count 与头部匹配
    assert payload_blob_count == payload.payload_blob_count

    assert payload_blob_count <= MAX_PAYLOAD_BLOBS_PER_BLOCK

    # 3. 提取类型 3 交易的版本哈希
    type3_versioned_hashes = []
    for tx in payload.transactions:
        if tx.type == BLOB_TX_TYPE:
            type3_versioned_hashes.extend(tx.blob_versioned_hashes)

    # 4. 验证版本哈希:先是负载 blob,然后是类型 3
    assert expected_blob_versioned_hashes == payload_versioned_hashes + type3_versioned_hashes

    # 5. 运行 EL STF(现在使用 header.payload_blob_count 检查正确的 blob_gas_used 和 blob 限制)
    return execute_payload(payload)

def blob_to_versioned_hash(blob: Blob) -> VersionedHash:
    commitment = blob_to_kzg_commitment(blob)
    return kzg_commitment_to_versioned_hash(commitment)
zkEVM 优化变体

此变体将 blob_to_kzg_commitment 中的 MSM 替换为多项式打开证明,这在 zkEVM 电路内部验证成本更低。负载、承诺和 KZG 证明是 zkEVM 电路的私有输入,而相应的版本哈希(和负载头部)是公共输入。

fn new_payload(
    payload: ExecutionPayload,
    expected_blob_versioned_hashes: List[VersionedHash], # 公共输入

    # BiB 新增:负载 blob 的前缀元数据
    payload_kzg_commitments: List[KZGCommitment],  # 私有输入
    payload_kzg_proofs: List[KZGProof],            # 私有输入
    ...
) -> PayloadStatus:

    # 0. 从头部声明的负载 blob 计数
    n = payload.payload_blob_count
    assert n <= MAX_PAYLOAD_BLOBS_PER_BLOCK
    assert len(payload_kzg_commitments) == n
    assert len(payload_kzg_proofs) == n

    # 1. 从执行负载数据构建负载 blob
    payload_data = get_execution_payload_data(payload)
    payload_blobs = execution_payload_data_to_blobs(payload_data)
    assert len(payload_blobs) == n

    # 2. 检查承诺是否对应预期的版本哈希前缀
    payload_versioned_hashes = [\
        kzg_commitment_to_versioned_hash(c) for c in payload_kzg_commitments\
    ]
    assert expected_blob_versioned_hashes[:n] == payload_versioned_hashes

    # 3. 使用批量 KZG 证明验证来检查 blob-承诺一致性
    assert verify_blob_kzg_proof_batch(
        blobs=payload_blobs,
        commitments=payload_kzg_commitments,
        proofs=payload_kzg_proofs
    )

    # 4. 继续进行标准的 EL 负载验证/执行
    return execute_payload(payload)

共识层

验证

共识层不会为负载 blob 引入超出 4844/7594 的新的 blob 特定验证规则。

共识层依赖执行负载头部中的 payload_blob_count 来解释 blob 承诺的顺序,但对于可用性和网络方面,负载 blob 的处理方式与其他 blob 完全相同。

网络

BiB 重用现有的 blob 网络机制。

我们提出以下需要考虑的事项:

  • 一旦证明变为强制性,将需要一种机制来检索执行负载。选项包括:
    • 一个单独的仅包含执行负载的 gossip 子主题
    • 允许下载前 payload_blob_count 个 blob 并使用解码算法重建执行负载。
  • 与大多数类型 3 blob 交易不同,负载 blob 在区块构建之前不会传播到网络。根据 ePBS 规定的时间限制,这可能意味着区块构建者需要更高的带宽。

费用计算

BiB 引入了协议强制要求的 blob 使用,而不是通过类型 3 交易由用户发起。因此,负载 blob 的费用计算在性质上不同于交易 blob 费用。

谁支付?

本 EIP 不强制要求负载 blob 像交易 blob 那样支付每 blob 费用。

相反,负载 blob 被视为构建者在构建区块时的开销,并由构建者内部消化。具体来说:

  • 负载 blob 不对应任何用户交易,因此自然无法映射为用户支付的 blob 费用。
  • 包含负载 blob 的成本(就 blob gas 使用而言)由构建者隐式支付,并反映在区块的盈利能力中。

负载 blob 会与交易 blob 竞争容量吗?

由于负载 blob 消耗 blob gas,它们直接影响 blob 拥塞和 blob 基本费用。

开放问题与未来考虑

  • 在 zk 之前,验证负载 blob 的成本也是由验证者承担的。因此,这些 blob 在某种意义上比普通 blob 更重。这应该在 blob_gas_used 中进行定价吗?
  • 网络相关:负载 blob 需要更高的带宽,因为它们不会出现在公共内存池中
  • 负载 blob 的显式协议级别定价

理性分析

执行负载数据包含哪些内容?

执行负载数据包括 transactionswithdrawalsrequests。这提供了一个自包含的“捆绑包”:例如,索引器可以在不访问 CL 状态或重执行的情况下解码 blob。

为什么不是头部? 头部不能放入 blob,因为它包含 payload_blob_count,这取决于 blob 的数量;会导致循环依赖。

注意:Withdrawals 和 requests 在技术上是冗余的(withdrawals 从 CL 派生,requests 可以通过执行交易重新计算)。

构建者自由裁量 vs 预留 k 个 blob

本 EIP 规定区块构建者选择 payload_blob_count,但受 MAX_BLOBS_PER_BLOCKMAX_PAYLOAD_BLOBS_PER_BLOCK 施加的限制。

另一种方法始终预留 k 个 blob,其中 k 对应最坏情况下的执行负载大小。虽然这提供了更好的可预测性,但在 blob 拥塞时会降低灵活性。

为什么不在核心 EL 执行逻辑内编码执行负载数据?

在 EL STF 中执行此操作需要使负载 blob 承诺或版本哈希在核心执行逻辑中可见,而不是在 Engine API 边界处处理。

用于编码执行负载数据的压缩算法

可以对序列化的执行负载数据使用压缩。这(理论上)应该允许使用更少的负载 blob,具体取决于压缩比。权衡包括:

  • 解压缩将消耗更多的 CPU/证明周期
  • 这是一个破坏性变更,因为我们希望在热路径上进行解压缩。这意味着交易在负载中需要被压缩,然后在我们尝试验证时解压缩。

是否应该使用压缩算法以及使用哪一种需要更多研究,特别是需要研究:

  • 实现的平均压缩比
  • 证明周期的开销
  • 现在需要对共识感知对象进行压缩以进行验证的侵入性

目前我们建议不采用任何压缩算法。

用于编码执行负载数据的序列化算法

执行负载数据的序列化使用 SSZ。也可以选择更适合零知识证明的序列化算法,但目前没有证据表明 SSZ 反序列化是瓶颈,并且使用 SSZ 不会引入额外的依赖。

至于 RLP,由于我们在 EL STF 之外序列化 ExecutionPayload,SSZ 是自然的选择。

关于 MAX_PAYLOAD_BLOBS_PER_BLOCK

鉴于负载 blob 优先于类型 3 交易,指定此值意味着构建者将无法仅用负载 blob 填满整个区块。

TODO:但是,让我们研究将其移除的可能性,因为还有其他变量/EIP 限制了区块大小,并且需要与费用计算协调一致。

向后兼容性

这需要修改执行负载头部和 EL STF;因此需要一次分叉。未实现 BiB 的节点将无法在激活后验证区块。

测试用例

待定

参考实现

待定

安全考虑

与 blob 拥塞和拒绝服务的交互

负载 blob 消耗 blob gas,因此与交易 blob 受相同的拥塞控制机制和 blob 限制的约束。

作为副产品,这确保了恶意区块生产者不能在不对 blob gas 限制负责的情况下创建任意大的执行负载。但是,我们注意到,区块生产者可以通过创建大型负载来提高 blob 基本费用。MAX_PAYLOAD_BLOBS_PER_BLOCK 为此攻击在每个区块的效果设定了上限,但这种情况需要更多研究;协议强制要求的行为是否应该收费?

数据隐藏

攻击者不能在不隐藏 blob 数据的情况下隐藏执行负载数据,否则将违反现有的 DAS 保证,并导致信标共识层拒绝该区块。

版权

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

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

0 条评论

请先 登录 后评论
kevaundray
kevaundray
江湖只有他的大名,没有他的介绍。