以太坊新提案:原生证明验证

本文提出通过泛化EIP-8025和引入新EIP,为以太坊增加通用证明验证原语。

摘要

本提案的总体目标是通过引入一种标准的 L1 原语,任何项目都可以用它来代替其定制的链上验证器堆栈,从而大幅降低 L2 桥的风险并简化其流程,以及更一般的、任何验证 ZK 证明的链上应用。这通过以下两项变更实现:

  1. 泛化 EIP-8025,使共识层的证明验证基础设施与程序无关,不局限于 EVM 执行证明。
  2. 一个新的 EIP,通过一种携带证明的交易类型和三个操作码(PROGRAMHASHPUBVALUESHASHPROOFCOUNT)将其暴露给智能合约。

两者结合,允许任何项目直接继承 L1 的证明验证基础设施,zkVM 的修复通过客户端发布而非每个项目的治理升级来交付。

动机

如今,每个以太坊 Rollup 都维护着定制的链上证明验证基础设施。ZK Rollup 部署 zkVM 验证器合约、适配器合约、多证明调度器和程序白名单逻辑。Optimistic Rollup 则部署自己的链上欺诈证明虚拟机(Arbitrum 的 WAVM、Optimism 的 Cannon MIPS 机器)以及相关的争议逻辑。在这两种情况下,每个合约都是独立维护、修补和升级的,以应对其特定证明系统或虚拟机中的 Bug,每次升级都通过自定义的多签或 DAO 来控制。这既缓慢又有风险,并且在生态系统中重复发生。

EIP-8025 在以太坊共识层引入了 zkVM 证明验证,但仅用于 L1 自身目的:验证执行载荷以实现无状态和次线性验证。Rollup 仍然需要自己的链上验证器合约。

然而,EIP-8025 带给 CL 的基础设施——ProofEngine、证明广播协议和验证逻辑——本质上并非 L1 特有的。如果将其泛化为与程序无关,并通过新的交易类型开放给智能合约,那么任何 Rollup(甚至是非 EVM 的 Rollup)都可以将证明验证卸载到 CL。当需要修补 zkVM 实现时,以太坊客户端团队只需发布更新的软件,就像今天修复 geth 或 Nethermind 中的 Bug 一样:通过客户端发布,无需硬分叉。这与原生 Rollup 背后的原理相同,但更为泛化:正如原生 Rollup 继承 L1 的执行环境一样,原生证明验证允许任何 Rollup 继承 L1 的证明验证基础设施。

尽管本文档围绕 Rollup 阐述这个提案,但同样的原语也服务于任何在链上验证 ZK 证明的合约:例如隐私系统、ZK 协处理器、身份认证、ZK ML 等。

Rollup 今天如何验证证明

每个 zkVM 供应商都会提供一个通用的 Solidity 验证器合约(通常是 BN254 上的 Groth16 或 Plonk 检查)。程序标识和公共值(电路承诺的任何输入和输出)与证明一起传递。对于 SP1:

interface ISP1Verifier {
    function verifyProof(
        bytes32 programVKey,         // 程序哈希
        bytes calldata publicValues, // 公共值(输入和/或输出)
        bytes calldata proofBytes    // 证明
    ) external view;
}

术语说明。 SP1 将 programVKey 称为“验证密钥”,但这与 zkVM 自身的电路验证密钥冲突。本文档将它们区分开来:

  • 程序哈希(SP1 称为 programVKey,Risc0 称为 imageId):一个 bytes32,标识编译后的客户程序。由于每个 zkVM 的编译方式不同(例如 RV32IMA 与 RV64IMA),因此它是每个 (源程序,zkVM) 对特有的。ERE 将其表示为每个后端的 zkVMVerifier::ProgramVk 关联类型(包装了 SP1VerifyingKey、Risc0 的 Digest 等)。
  • 验证密钥:zkVM 的电路 VK(多项式承诺、域参数)。作为常量硬编码在链上验证器中,每个 zkVM 版本一个,所有程序共享。

示例:Taiko(多验证器)

Taiko 展示了当一个 Rollup 使用多个证明系统时所带来的复杂性。其验证架构涉及跨三个层次的六个合约(两个原始验证器、两个适配器、一个调度器和一个 SGX 验证器),每个都通过自定义的多签独立维护和升级。

1. 原始 zkVM 验证器。 Taiko 部署了一个 SP1 Plonk 验证器(SP1Verifier.sol)和一个 Risc0 Groth16 验证器(RiscZeroGroth16Verifier.sol)。这些是供应商提供的通用验证器合约。

2. Taiko 特定的适配器。 每个原始验证器都被包装在一个实现 Taiko 的 IVerifier 接口的适配器合约中:

// TaikoSP1Verifier: SP1 的适配器
contract TaikoSP1Verifier is IVerifier {
    address public sp1RemoteVerifier;                    // 原始 SP1 验证器
    mapping(bytes32 => bool) public isProgramTrusted;    // 白名单程序

    function verifyProof(Context[] calldata _ctxs, bytes calldata _proof) external view {
        bytes32 aggregationProgram = bytes32(_proof[:32]);
        bytes32 blockProvingProgram = bytes32(_proof[32:64]);
        require(isProgramTrusted[aggregationProgram]);
        require(isProgramTrusted[blockProvingProgram]);

        bytes memory publicInputs = buildPublicInputs(_ctxs);
        ISP1Verifier(sp1RemoteVerifier).verifyProof(
            aggregationProgram, publicInputs, _proof[64:]
        );
    }
}

一个并行的 Risc0Verifier 具有相同的结构,其中 isImageTrusted 替换了 isProgramTrustedsha256(buildPublicInputs(...)) 作为日志摘要。

3. 多验证器调度器。 一个 ComposeVerifier 合约协调多个验证器,并强制要求每个证明必须由足够多的验证器集验证:

contract MainnetVerifier is ComposeVerifier {
    address public immutable sgxGethVerifier;    // SGX 验证器(必需)
    address public immutable risc0RethVerifier;  // Risc0 选项
    address public immutable sp1RethVerifier;    // SP1 选项

    function verifyProof(Context[] calldata _ctxs, bytes calldata _proof) external {
        SubProof[] memory subProofs = abi.decode(_proof, (SubProof[]));
        for (uint256 i = 0; i < subProofs.length; ++i) {
            IVerifier(subProofs[i].verifier).verifyProof(_ctxs, subProofs[i].proof);
        }
        require(areVerifiersSufficient(verifiers));
    }

    function areVerifiersSufficient(address[] memory _verifiers) internal view override {
        // 必须正好有 2 个:sgxGethVerifier + (risc0 或 sp1)
    }
}

对 EIP-8025 的变更

EIP-8025 为 L1 区块验证引入了可选的执行证明。它带给共识层的基础设施(ProofEngine、广播协议、验证逻辑)之所以是 L1 特定的,仅仅是因为其类型是 L1 特定的:ExecutionProof.public_input 包含一个 new_payload_request_root: Root,而 ProofType 是一个 uint8,枚举了一小组固定的、已接受的 (客户端,zkVM) 构建(参见 Lighthouse 实现):

ProofType 客户程序 zkVM 后端
0 ethrex Risc0
1 ethrex SP1
2 ethrex Zisk
3 reth OpenVM
4 reth Risc0
5 reth SP1
6 reth Zisk

当客户程序集合很小且事先已知时,这可以工作,但无法容纳任意 Rollup 程序。

本 EIP 在此基础上添加了一个通用的验证原语,而 EIP-8025 现有的接口(ExecutionProofProofTypeverify_execution_proofnotify_new_payloadnotify_forkchoice_updatedprocess_execution_proofrequest_proofsProofAttributes)保持不变。这种泛化类似于 ERE,其 zkVMVerifier trait 是与程序无关的,特定的客户程序在此基础上构建。遵循 ERE 的设计——其中 CompilerzkVMVerifier 后端是独立的 trait——新的 Proof 容器将原先合并的 ProofType 分解为两个维度:一个 BackendType: uint8,仅标识 zkVM 后端;以及一个 program_hash: Bytes32,标识客户程序(特定于 (客户程序,zkVM) 对,参见术语说明)。引擎使用 backend_type 来选择电路 VK;program_hash 是电路的一个公共输入,在验证过程中与 public_values 一起检查:

class ProofPublicInput(Container):
    program_hash: Bytes32
    public_values: ByteList[MAX_PUBLIC_VALUES_SIZE]

class Proof(Container):
    proof_data: ByteList[MAX_PROOF_SIZE]
    backend_type: BackendType
    public_input: ProofPublicInput

def verify_proof(self: ProofEngine, proof: Proof) -> bool: ...

EIP-8025 的 verify_execution_proof 可以重新实现为 verify_proof 的一个薄包装器,以实现代码共享,在广播协议层没有可观察的变化:

def verify_execution_proof(self: ProofEngine, ep: ExecutionProof) -> bool:
    backend_type, program_hash = self.resolve_proof_type(ep.proof_type)
    expected_public_values = serialize_stateless_output(StatelessValidationResult(
        new_payload_request_root=ep.public_input.new_payload_request_root,
        successful_validation=True,
        chain_config=self.chain_config,
    ))
    return self.verify_proof(Proof(
        proof_data=ep.proof_data,
        backend_type=backend_type,
        public_input=ProofPublicInput(
            program_hash=program_hash,
            public_values=expected_public_values,
        ),
    ))

serialize_stateless_outputStatelessValidationResult 上的字节级布局在对原生 Rollup 的影响中展示,因为原生 Rollup 合约在链上重建它。区块有效性仍然与证明验证解耦;诚实的证明者指南保持不变。通过侧车到达的证明(携带证明的交易,参见证明传播)直接通过 verify_proof 处理,无需 L1 包装器。

程序哈希稳定性(待解决问题)

原生证明验证的特性——“修复通过客户端发布交付,链上无需更改”——依赖于一个重要要求:链上固定的 program_hash 必须在 zkVM 补丁之间保持稳定。如果任何补丁改变了哈希值,那么固定了旧值的 Rollup 就会被阻塞,除非它们升级,而升级就又回到了链上治理的老路。

目前,没有哪个 zkVM 能直接提供这一点。两个主要的候选方案都对在普通 SDK / 依赖 / 工具链变化中会改变的人工产物进行指纹记录,而不仅仅是电路层的修复:

  • Risc0 的 imageId 是对 SystemState { pc: 0, merkle_root } 的 SHA-256 哈希,其中 merkle_root 是初始内存镜像的 Poseidon2 默克尔根,包含用户 ELF 和内核 ELF(binfmt/src/elf.rs#L435)。内存镜像捕获了确切的编译后字节,因此即使 STF 语义不变,依赖项更新、工具链更新或内核补丁都会改变 imageId
  • SP1 的 programVKey 是对 (preprocessed_commit, pc_start, ...) 的 Poseidon2 哈希(hypercube/src/verifier/hashable_key.rs#L107)。与 Risc0 的 imageId(编译后字节的纯哈希)不同,SP1 的 vk 是在 ELF 上运行电路设置的副产品:preprocessed_commit 是 AIR 的预处理承诺,pc_start 来自链接器,因此电路更改、SDK 版本更新和工具链更改都会改变它,即使客户的 Guest 源代码逐字节相同。

直接使用它们作为链上 program_hash 会导致每次 zkVM 发布都成为 Rollup 可见的事件。

现实的路径是引入一个 间接层:链上 program_hash 是一个稳定的、由 Rollup 选择的标识符,并作为证明的公共输入;而 zkVM 内部的标识符是一个私有输入,由客户端维护,并可以在每次发布时自由更改。证明必须证明两者是关联的,这样稳定的 program_hash 才能真正承诺被执行的内容。确切的机制是一个开放的设计问题。

使用 NATIVE_PROGRAM 哨兵值的原生 Rollup 完全绕过了这个问题:哨兵值只是说“无论 L1 当前接受什么”,而接受的集合本身是一个客户端侧的人工产物,随着 zkVM 的发布而更新。

新的 EIP:携带证明的交易

交易格式

TransactionType: PROOF_TX_TYPE

TransactionPayloadBody:
[chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit,\
 to, value, data, access_list, max_fee_per_blob_gas,\
 blob_versioned_hashes, proofs, public_values_hash,\
 y_parity, r, s]

其中:

  • proofs:一个由 (program_hash, backend_type) 对组成的列表。每个 program_hash 是一个 bytes32,标识该特定 zkVM 后端的客户程序(参见术语说明)。每个 backend_type 是一个 uint8,并且在列表中必须唯一,因为来自同一后端的两个证明不会增加安全性。此列表的长度决定了 proof_count
  • public_values_hash:程序公共输出的 bytes32 哈希值(所有证明共享,因为所有后端证明相同的语句)。

CL 级别的 Proof 携带原始的 public_values 字节;交易体(以及 PUBVALUESHASH 操作码)只公开它们的哈希值。合约重建预期的字节并比较哈希值。两个不变条件将两个视图联系在一起(由任何处理侧车的节点检查,在内存池传播时和构建者组装区块时都会再次检查):

  • sidecar[i].public_input.program_hash == proofs[i].program_hashsidecar[i].backend_type == proofs[i].backend_type
  • sha256(sidecar[i].public_input.public_values) == public_values_hash

这些将 EVM 可见的标识符(proofs[i].program_hashpublic_values_hash)与传递给 verify_proof 的底层 Proof 对象绑定在一起。参见证明传播了解证明如何到达构建者,以及 L1 区块证明如何覆盖它们。

操作码

新的操作码用于读取携带证明的交易中的字段,对于非携带证明的交易返回零值。

操作码 输入 输出 描述
PROGRAMHASH index program_hash (bytes32) 第 i 个证明的程序哈希。索引方式类似 BLOBHASH;如果 index >= PROOFCOUNT() 则返回 bytes32(0)
PUBVALUESHASH public_values_hash (bytes32) 程序公共输出的哈希值(所有证明共享)
PROOFCOUNT proof_count (uint8) 交易 proofs 列表的长度

自定义 Rollup 使用 PROOFCOUNT() 进行遍历,并检查每个 PROGRAMHASH(i) 是否在其自身的白名单中。

对于原生 Rollup,当第 i 个证明使用的是 L1 当前为其自身 EVM 执行证明所接受的程序时,PROGRAMHASH(i) 返回一个众所周知的哨兵值(例如 bytes32(1))。这样,合约只需检查 PROGRAMHASH(i) == NATIVE_PROGRAM,而无需存储每个 zkVM 的特定哈希值,并自动跟随客户端发布中交付的 L1 升级。

多证明

proofs 列表允许每个 Rollup 选择自己的安全与成本权衡:[(hash, SP1)] 为单个证明,[(hash_sp1, SP1), (hash_risc0, Risc0)] 则要求同一个语句在 CL 接受交易之前由两者独立证明。合约读取 PROOFCOUNT() 并强制其自身的最低要求。

这取代了合约级别的多证明编排(如 Taiko 的 ComposeVerifier 要求同时有 SGX 和 ZK 验证器),代之以协议级别的机制。由于 proofs 位于已签名的交易体中,因此无法被篡改。

证明传播

证明必须通过内存池到达构建者,但无需长期可用。提议的方法是采用 临时侧车:证明像 EIP-4844 的 blob 侧车一样随交易一起传输。内存池节点和构建者在转发或包含交易之前,对每个侧车条目运行 verify_proof(并检查交易格式中的不变条件)。然后,构建者在将区块纳入之前剥离侧车,将其折叠进递归的 L1 区块证明中,然后丢弃。验证者只看到交易体(proofs 列表和 public_values_hash)加上 L1 区块证明;它们永远不需要原始的证明字节。因此,L1 区块证明递归地覆盖了区块中的每个携带证明的交易(后量子证明可能足够大,以至于 L1 每个插槽只能允许一个证明)。

大小。 EIP-8025 设置每个证明的大小为 MAX_PROOF_SIZE = 400 KiB。规范没有限制 len(proofs),但内存池客户端的大小限制使其实际数目上限为 2-3 个。

对现有 Rollup 的影响

下表报告了每个项目链上合约的 Solidity SLOC(非空白、非注释的源代码行),分为“核心”Rollup 逻辑和原生证明验证将淘汰的证明验证堆栈。

项目 证明系统 核心 SLOC 淘汰 SLOC 淘汰百分比
Arbitrum 乐观,WASM VM 19,034 8,181 43.0%
Base 乐观,MIPS VM 17,426 8,907 51.1%
ZKsync Era 有效性,EraVM 10,823 2,379 22.0%
Linea 有效性,直接 EVM 8,111 2,460 30.3%
Lighter 有效性,无 VM(自定义电路) 5,417 1,699 31.4%
合计 60,811 23,626 38.9%

这些数字是粗略估计。它们只涵盖了链上 Solidity 代码,不包括离线证明者、排序器和每个 program_hash 背后的客户程序。治理表面(多签、时间锁、DAO 合约、代理管理员)、合作伙伴特定的桥和代理样板在两列中都被排除。

Taiko 的六个合约组成的多验证器堆栈被简化为一个单一的收件箱合约:

contract TaikoInbox {
    mapping(bytes32 => bool) public isTrustedProgram;  // 每个 zkVM 程序哈希的白名单
    uint256 public minProofCount;                       // 多证明阈值(例如 2)

    function proveBatches(
        BatchMetadata[] calldata metas,
        Transition[] calldata trans
        // _proof 参数已移除:由 CL 验证
    ) external {
        // 验证所有证明使用了受信任的程序。
        require(PROOFCOUNT() >= minProofCount, "证明数量不足");
        for (uint256 i = 0; i < PROOFCOUNT(); i++) {
            require(isTrustedProgram[PROGRAMHASH(i)], "未受信任的程序");
        }

        bytes memory publicInputs = buildPublicInputs(metas, trans);
        require(PUBVALUESHASH() == sha256(publicInputs), "公共值错误");

        // 接受这些批次。
        ...
    }
}

单个 isTrustedProgram 白名单取代了 isProgramTrusted (SP1) 和 isImageTrusted (Risc0);minProofCount 取代了 areVerifiersSufficient

对原生 Rollup 的影响

来自原生 Rollup 的 ZK 规范的 NativeRollup 合约使用相同的模式。它不针对 validation_result_root 检查 PROOFROOT,而是检查 PROGRAMHASHPUBVALUESHASHPROOFCOUNT

bytes32 constant NATIVE_PROGRAM = bytes32(uint256(1));
uint256 public minProofCount;

function advance(BlockParams calldata params) external {
    bytes32 l1Anchor = blockhash(block.number - 1);

    bytes32 npRoot = computeNewPayloadRequestRoot(
        blockHash, params.feeRecipient, params.stateRoot,
        // ... 其余字段 ...
        getVersionedHashes(params.payloadBlobCount),
        l1Anchor, bytes32(0)
    );

    // SSZ 编码 StatelessValidationResult 容器:
    //   new_payload_request_root (32 bytes) || successful_validation (1 byte)
    //   || chain_id (8 bytes,小端序)
    // 必须与 execution-specs 中的 serialize_stateless_output() 匹配。
    bytes memory expectedPublicValues = SSZ.encodeStatelessValidationResult(
        npRoot, true, chainId
    );
    bytes32 expectedPubValuesHash = sha256(expectedPublicValues);

    require(PROOFCOUNT() >= minProofCount, "证明数量不足");
    for (uint256 i = 0; i < PROOFCOUNT(); i++) {
        require(PROGRAMHASH(i) == NATIVE_PROGRAM, "不是原生程序");
    }
    require(PUBVALUESHASH() == expectedPubValuesHash, "公共值哈希错误");

    blockHash = params.blockHash;
    stateRoot = params.stateRoot;
    blockNumber = blockNumber + 1;
    stateRootHistory[blockNumber] = params.stateRoot;
}

原生 Rollup 就是其 programHash 与 L1 自身接受的程序匹配的 Rollup;L1 的升级(例如改变 verify_stateless_new_payload 的分叉)会自动传播。具有自定义虚拟机的 Rollup 使用相同的模式,但使用不同的 programHash

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

0 条评论

请先 登录 后评论
以太坊中文
以太坊中文
以太坊中文, 用中文传播以太坊的最新进展