
以太坊原生證明驗證機制
本提案旨在透過引入標準的 L1 原語來簡化 L2 橋接與鏈上 ZK 證明驗證,並將 EIP-8025 通用化,讓任何專案都能直接繼承以太坊共識層的證明驗證基礎設施。
摘要
本提案的首要目標是透過引入一個標準的 L1 原語(primitive),讓任何專案都能採用它來取代自定義的鏈上驗證器堆疊,從而大幅降低 L2 橋接器以及更廣泛的任何驗證 ZK 證明的鏈上應用程式的風險並簡化其流程。這將透過兩項變更來實現:
-
泛化 EIP-8025:使共識層的證明驗證基礎設施變得與程式無關(program-agnostic),不再僅限於 EVM 執行證明。
-
一項新的 EIP:透過一種帶有證明的交易類型(proof-carrying transaction type)和三個操作碼(PROGRAMHASH、PUBVALUESHASH、PROOFCOUNT)將其暴露給智能合約。
結合這兩點,任何專案都能直接繼承 L1 的證明驗證基礎設施,而 zkVM 的修復將透過客戶端版本發佈,而非各個專案獨立的治理升級。
動機
現今,每個以太坊 Rollup 都維護著自定義的鏈上證明驗證基礎設施。ZK Rollup 部署 zkVM 驗證器合約、適配器合約、多重證明調度器以及程式白名單邏輯。Optimistic Rollup 則發佈自己的鏈上欺詐證明 VM(如 Arbitrum 的 WAVM、Optimism 的 Cannon MIPS 機器)以及相關的爭議邏輯。在上述兩種情況下,每個合約都必須針對其特定證明系統或 VM 的漏洞進行獨立的維護、補丁和升級,且每次升級都受限於自定義的多簽或 DAO。這不僅緩慢、危險,且在整個生態系統中造成了重複勞動。
EIP-8025 在以太坊共識層引入了 zkVM 證明驗證,但僅用於 L1 自身的目的:驗證執行負載以實現無狀態和次線性驗證。Rollup 仍然需要自己的鏈上驗證器合約。
然而,EIP-8025 為共識層(CL)帶來的基礎設施——證明引擎(ProofEngine)、證明傳播(proof gossip)和驗證邏輯——本質上並非 L1 專用。如果將其泛化為與程式無關,並透過新的交易類型暴露給智能合約,任何 Rollup(甚至是基於非 EVM 的)都可以將證明驗證委託給共識層。當 zkVM 實作需要修復時,以太坊客戶端團隊會發佈更新軟體,就像今天修復 geth 或 Nethermind 的漏洞一樣:透過客戶端發佈版本,而無需硬分叉。這與 原生 Rollup (native rollups) 的原理相同,但更具普適性:正如原生 Rollup 繼承 L1 的執行環境,原生證明驗證讓任何 Rollup 都能繼承 L1 的證明驗證基礎設施。
雖然本文圍繞 Rollup 展開提案,但同樣的原語也適用於任何在鏈上驗證 ZK 證明的合約:隱私系統、ZK 協處理器、身份驗證、ZK ML 等。
Rollup 今日如何驗證證明
每個 zkVM 供應商都會提供一個通用的 Solidity 驗證器合約(通常是基於 BN254 的 Groth16 或 Plonk 檢查)。程式識別碼和公共值(電路承諾的任何輸入和輸出)會隨證明一起傳遞。以 SP1 為例:
interface ISP1Verifier {
function verifyProof(
bytes32 programVKey, // 程式雜湊 (program hash)
bytes calldata publicValues, // 公共值 (輸入和/或輸出)
bytes calldata proofBytes // 證明數據
) external view;
}
關於術語的說明: SP1 將 programVKey 稱為「驗證密鑰 (verification key)」,但這會與 zkVM 自身的電路驗證密鑰混淆。本文將兩者分開:
-
程式雜湊 (Program hash)(SP1 稱為 programVKey,Risc0 稱為 imageId):一個識別已編譯客體程式(guest program)的 bytes32。由於每個 zkVM 的編譯方式不同(例如 RV32IMA vs RV64IMA),它是針對每個(原始碼, zkVM)配對的。ERE 將其表示為每個後端的 zkVMVerifier::ProgramVk 關聯類型(封裝了 SP1VerifyingKey、Risc0 的 Digest 等)。
-
驗證密鑰 (Verification key):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 取代 isProgramTrusted,並使用 sha256(buildPublicInputs(...)) 作為日誌摘要(journal digest)。
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 區塊驗證引入了可選的執行證明。它為共識層帶來的基礎設施(證明引擎、傳播、驗證邏輯)之所以是 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 後端是獨立的特性,新的 Proof 容器將原本混淆的 ProofType 拆分為兩個軸:一個識別 zkVM 後端的 BackendType: uint8,以及一個識別客體程式的 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,
),
))
StatelessValidationResult 上的 serialize_stateless_output 位元組級佈局顯示在對原生 Rollup 的影響中,因為原生 Rollup 合約會在鏈上重建它。區塊有效性與證明驗證保持解耦;誠實證明者指南保持不變。透過邊車(sidecar)到達的證明(帶證明的交易,參見證明傳播)直接通過 verify_proof,無需 L1 封裝。
程式雜湊穩定性(開放問題)
原生證明驗證「修復透過客戶端發佈,鏈上無須變動」的特性取決於一個非平凡的要求:固定在鏈上的 program_hash 必須在 zkVM 補丁更新中保持穩定。如果任何補丁改變了雜湊值,固定了舊值的 Rollup 將會失效(bricked),除非它們進行升級,而這會使升級方案退回到鏈上治理的老路。
目前沒有任何 zkVM 能直接提供這種穩定性。兩個領先的候選方案都會對在正常的 SDK / 依賴項 / 工具鏈變動下會發生變化的產物進行指紋識別,而不僅僅是電路層的修復:
-
Risc0 的 imageId 是對
SystemState { pc: 0, merkle_root }進行的 SHA-256 雜湊,其中merkle_root是初始記憶體映像的 Poseidon2 默克爾根,該映像包含用戶 ELF 和內核 ELF (binfmt/src/elf.rs#L435)。記憶體映像捕捉了精確的編譯位元組,因此依賴項更新、工具鏈更新或內核補丁都會改變imageId,即使 STF 語義未變。 -
SP1 的 programVKey 是對
(preprocessed_commit, pc_start, ...)進行的 Poseidon2 雜湊 (hypercube/src/verifier/hashable_key.rs#L107)。與 Risc0 的imageId(純編譯位元組雜湊)不同,SP1 的 VK 是在 ELF 上運行電路設置(circuit setup)的副產品:preprocessed_commit是 AIR 的預處理承諾,而pc_start來自鏈接器,因此電路變更、SDK 更新和工具鏈變更都會使其變動,即使客體原始碼在位元組層面是相同的。
直接使用其中任何一個作為鏈上 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_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) | 交易證明列表的長度 |
自定義 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 位於已簽名的交易主體中,因此無法被篡改。
證明傳播
證明必須透過內存池到達構建者,但不需要長期可用性。建議的方法是臨時邊車 (ephemeral sidecar):證明像 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 | Optimistic, WASM VM | 19,034 | 8,181 | 43.0% |
| Base | Optimistic, MIPS VM | 17,426 | 8,907 | 51.1% |
| ZKsync Era | Validity, EraVM | 10,823 | 2,379 | 22.0% |
| Linea | Validity, direct EVM | 8,111 | 2,460 | 30.3% |
| Lighter | Validity, 無 VM (自定義電路) | 5,417 | 1,699 | 31.4% |
| 總計 | 60,811 | 23,626 | 38.9% |
這些數字是粗略估計。它們僅涵蓋鏈上 Solidity 代碼,不包括離線證明者、排序器以及每個 program_hash 背後的客體程式。治理介面(多簽、時間鎖、DAO 合約、代理管理員)、特定合作夥伴的橋接器和代理樣板代碼均不計入兩列。
Taiko 的六合約多重驗證器堆疊將簡化為單個收件箱(inbox)合約:
contract TaikoInbox {
mapping(bytes32 => bool) public isTrustedProgram; // 每個 zkVM 的白名單程式雜湊
uint256 public minProofCount; // 多重證明閾值 (例如 2)
function proveBatches(
BatchMetadata[] calldata metas,
Transition[] calldata trans
// _proof 參數已移除:由共識層驗證
) external {
// 驗證所有證明是否使用了受信任的程式。
require(PROOFCOUNT() >= minProofCount, "insufficient proofs");
for (uint256 i = 0; i < PROOFCOUNT(); i++) {
require(isTrustedProgram[PROGRAMHASH(i)], "untrusted program");
}
bytes memory publicInputs = buildPublicInputs(metas, trans);
require(PUBVALUESHASH() == sha256(publicInputs), "wrong public values");
// 接受批次。
...
}
}
單個 isTrustedProgram 白名單取代了 isProgramTrusted (SP1) 和 isImageTrusted (Risc0);minProofCount 取代了 areVerifiersSufficient。
對原生 Rollup 的影響
來自原生 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 位元組) || successful_validation (1 位元組)
// || chain_id (8 位元組, 小端序)。
// 必須與 execution-specs 中的 serialize_stateless_output() 匹配。
bytes memory expectedPublicValues = SSZ.encodeStatelessValidationResult(
npRoot, true, chainId
);
bytes32 expectedPubValuesHash = sha256(expectedPublicValues);
require(PROOFCOUNT() >= minProofCount, "insufficient proofs");
for (uint256 i = 0; i < PROOFCOUNT(); i++) {
require(PROGRAMHASH(i) == NATIVE_PROGRAM, "not a native program");
}
require(PUBVALUESHASH() == expectedPubValuesHash, "wrong public values");
blockHash = params.blockHash;
stateRoot = params.stateRoot;
blockNumber = blockNumber + 1;
stateRootHistory[blockNumber] = params.stateRoot;
}
原生 Rollup 僅僅是其 programHash 與 L1 自身接受的內容相匹配的 Rollup;L1 升級(例如改變 verify_stateless_new_payload 的分叉)會自動傳播。使用自定義 VM 的 Rollup 則使用相同的模式,但具有不同的 programHash。
1 則貼文 - 1 位參與者
[閱讀完整主題](https://ethresear.ch/t/native-proof-verification/24798)
相關文章