本文提出通过泛化EIP-8025和引入新EIP,为以太坊增加通用证明验证原语。
本提案的总体目标是通过引入一种标准的 L1 原语,任何项目都可以用它来代替其定制的链上验证器堆栈,从而大幅降低 L2 桥的风险并简化其流程,以及更一般的、任何验证 ZK 证明的链上应用。这通过以下两项变更实现:
PROGRAMHASH、PUBVALUESHASH、PROOFCOUNT)将其暴露给智能合约。两者结合,允许任何项目直接继承 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 等。
每个 zkVM 供应商都会提供一个通用的 Solidity 验证器合约(通常是 BN254 上的 Groth16 或 Plonk 检查)。程序标识和公共值(电路承诺的任何输入和输出)与证明一起传递。对于 SP1:
interface ISP1Verifier {
function verifyProof(
bytes32 programVKey, // 程序哈希
bytes calldata publicValues, // 公共值(输入和/或输出)
bytes calldata proofBytes // 证明
) external view;
}
术语说明。 SP1 将 programVKey 称为“验证密钥”,但这与 zkVM 自身的电路验证密钥冲突。本文档将它们区分开来:
programVKey,Risc0 称为 imageId):一个 bytes32,标识编译后的客户程序。由于每个 zkVM 的编译方式不同(例如 RV32IMA 与 RV64IMA),因此它是每个 (源程序,zkVM) 对特有的。ERE 将其表示为每个后端的 zkVMVerifier::ProgramVk 关联类型(包装了 SP1VerifyingKey、Risc0 的 Digest 等)。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 替换了 isProgramTrusted,sha256(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 为 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 现有的接口(ExecutionProof、ProofType、verify_execution_proof、notify_new_payload、notify_forkchoice_updated、process_execution_proof、request_proofs、ProofAttributes)保持不变。这种泛化类似于 ERE,其 zkVMVerifier trait 是与程序无关的,特定的客户程序在此基础上构建。遵循 ERE 的设计——其中 Compiler 和 zkVMVerifier 后端是独立的 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_output 在 StatelessValidationResult 上的字节级布局在对原生 Rollup 的影响中展示,因为原生 Rollup 合约在链上重建它。区块有效性仍然与证明验证解耦;诚实的证明者指南保持不变。通过侧车到达的证明(携带证明的交易,参见证明传播)直接通过 verify_proof 处理,无需 L1 包装器。
原生证明验证的特性——“修复通过客户端发布交付,链上无需更改”——依赖于一个重要要求:链上固定的 program_hash 必须在 zkVM 补丁之间保持稳定。如果任何补丁改变了哈希值,那么固定了旧值的 Rollup 就会被阻塞,除非它们升级,而升级就又回到了链上治理的老路。
目前,没有哪个 zkVM 能直接提供这一点。两个主要的候选方案都对在普通 SDK / 依赖 / 工具链变化中会改变的人工产物进行指纹记录,而不仅仅是电路层的修复:
imageId 是对 SystemState { pc: 0, merkle_root } 的 SHA-256 哈希,其中 merkle_root 是初始内存镜像的 Poseidon2 默克尔根,包含用户 ELF 和内核 ELF(binfmt/src/elf.rs#L435)。内存镜像捕获了确切的编译后字节,因此即使 STF 语义不变,依赖项更新、工具链更新或内核补丁都会改变 imageId。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 的发布而更新。
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_hash 且 sidecar[i].backend_type == proofs[i].backend_type。sha256(sidecar[i].public_input.public_values) == public_values_hash。这些将 EVM 可见的标识符(proofs[i].program_hash、public_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 个。
下表报告了每个项目链上合约的 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 的 ZK 规范的 NativeRollup 合约使用相同的模式。它不针对 validation_result_root 检查 PROOFROOT,而是检查 PROGRAMHASH、PUBVALUESHASH 和 PROOFCOUNT:
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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码