导语:证明你知道某一个秘密,却不泄露秘密本身
零知识证明(Zero-Knowledge Proof,ZKP)是密码学中最反直觉、也最强大的工具之一。想象一个场景:你向朋友证明你知道某个仓库的密码,但你不需要把密码告诉对方——你可以直接走进去把仓库里的一块石头拿出来,以此证明你确实知道密码。
在区块链领域,零知识证明解决了两个核心问题:
- 可扩展性(Scalability):链下执行计算,链上只验证一个简洁的证明
- 隐私性(Privacy):证明某个状态转换合法,但隐藏输入细节
一句话总结:零知识证明的魔力在于"一证一验"——证明者做大量计算生成本地证据,验证者只需常数时间验证——这把区块链的"每个节点都重算"变成了"每个节点只验证一个短证明"。
1. 零知识证明的直观理解
1.1 一个经典比喻:阿里巴巴的洞穴
洞穴有两个入口 A 和 B,中间有一扇需要秘密咒语才能打开的门。
证明者(知道咒语的人):
1. 进入 A 或 B(选择不告诉验证者)
2. 验证者随机喊"从 A 出来"或"从 B 出来"
3. 证明者念咒语通过门,从指定方向走出来
如果证明者不知道咒语,每次蒙对的概率只有 50%。
重复 20 次,蒙对的概率只有 1/2^20(约百万分之一)——
验证者以极高的概率确信证明者知道咒语,但验证者从未听到咒语内容。
1.2 零知识证明的三性质
| 性质 | 含义 | 区块链意义 |
|---|---|---|
| 完备性(Completeness) | 如果陈述为真,诚实的证明者能让验证者信服 | 正确的链下计算一定能生成有效证明 |
| 可靠性(Soundness) | 如果陈述为假,任何证明者都无法让验证者信服 | 恶意节点无法伪造证明偷取资金 |
| 零知识性(Zero-Knowledge) | 验证者从证明中获取不到额外信息 | 交易细节不暴露到链上,保护隐私 |
一句话总结:完备性保证诚实的计算能通过,可靠性保证作恶的证明能通过,零知识性保证隐私能得到保护——这就是 ZKP 作为密码学基座的三根支柱。
2. zk-SNARKs:简洁的非交互式证明
2.1 zk-SNARKs 的核心特性
zk-SNARKs(Zero-Knowledge Succinct Non-Interactive Argument of Knowledge)的三大特征:
Succinct(简洁):验证时间 O(1),证明大小几百字节
Non-Interactive(非交互):证明只需一轮,证明者生成,验证者验证
Argument of Knowledge(知识论证):证明者不仅知道 x 满足关系,还"知道"为什么
2.2 可信设置(Trusted Setup)
zk-SNARKs 的大多数早期系统(如 Groth16)需要一次可信设置仪式(Trusted Setup Ceremony):
可信设置过程:
1. 生成公共参考字符串(CRS,Common Reference String)
2. 过程中产生一个"有毒废料"(Toxic Waste),知道它的人可以伪造证明
3. 必须确保有毒废料被销毁
仪式安全:
- 多参与者仪式(如 Zcash 的 Powers of Tau)
- 只要有一人诚实销毁废料 → 整个系统安全
- 可用 Perpetual Powers of Tau 让不同项目复用同一仪式
2.3 Groth16:最成熟的 zk-SNARK 方案
Groth16 是目前证明大小最小(约 192 字节)、验证最快的 zk-SNARK 系统,被 Zcash、Filecoin 等采用。
// Groth16 验证合约在以太坊上的核心逻辑
// 使用 bn128 曲线配对检查
function verifyProof(
uint256[2] memory a, // 证明元素 A(G1)
uint256[2][2] memory b, // 证明元素 B(G2)
uint256[2] memory c, // 证明元素 C(G1)
uint256[1] memory input // 公开输入
) public view returns (bool) {
// 验证:e(A, B) == e(alpha, beta) * e(C, delta) * e(IC, gamma)
// 使用以太坊的 bn128Pairing 预编译合约
require(input.length + 1 == vk.IC.length, "Input mismatch");
uint256[24] memory pairingData;
// ... 构造配对数据 ...
bool result;
assembly {
// 调用预编译合约 0x08(bn128 paring check)
let success := staticcall(sub(gas(), 2000), 8, pairingData, 0x300, result, 0x20)
}
return result;
}
2.4 递归证明
递归证明是 zk-SNARKs 的高级特性:一个证明可以证明另一个证明的正确性,从而无限压缩。
应用场景:zkRollup 批量证明
交易 1 → 证明 1
交易 2 + 证明 1 → 证明 2(证明"交易 2 合法"且"证明 1 验证通过")
交易 3 + 证明 2 → 证明 3
...
最终:证明 N 证明了全部 N 笔交易,但大小仍然 O(1)
代表项目:
- Scroll(递归证明聚合)
- Polygon zkEVM(批次递归)
一句话总结:zk-SNARKs 用简洁的证明换来了极致的验证效率,但可信设置是阿喀琉斯之踵——Groth16 适合固定电路的场景,PlonK 通过通用可信设置降低了重复成本。
3. zk-STARKs:无需可信设置的替代方案
3.1 zk-STARKs 的核心优势
zk-STARKs(Scalable Transparent Argument of Knowledge)与 zk-SNARKs 的关键区别:
| 维度 | zk-SNARKs | zk-STARKs |
|---|---|---|
| 可信设置 | 需要(部分系统已免) | 不需要(完全透明) |
| 证明大小 | ~200 字节 | 数十 KB ~ 数百 KB |
| 验证成本 | 低(单次配对数个毫秒) | 较高(多次哈希运算) |
| 证明生成速度 | 较慢(需电路优化) | 较快,可大规模并行 |
| 抗量子计算 | 部分不是(依赖椭圆曲线) | 是(仅依赖哈希函数) |
| 代表项目 | Zcash、zkSync | Starknet |
3.2 zk-STARKs 的技术基础
zk-STARKs 基于多项式承诺(Polynomial Commitment)和FRI 协议(Fast Reed-Solomon Interactive Oracle Proof of Proximity):
核心思路:
1. 把计算过程编码成多项式
2. 承诺多项式的值(压缩表示)
3. 用 FRI 协议证明承诺值确实"靠近"某个低次多项式
4. 零知识:在多项式中混淆随机数
安全性来源:
- 仅依赖抗碰撞哈希函数(如 Keccak-256)
- 不需要椭圆曲线配对的特殊代数结构
- 不需要可信设置
- 天然抗量子计算攻击
一句话总结:zk-STARKs 用"更大的证明 + 更高的验证开销"换回了"零信任假设 + 抗量子安全",是 zk-SNARKs 的理想替代方案,尤其适合不需要极致链上验证效率的场景。
4. zkEVM:把 EVM 塞进电路
4.1 zkEVM 兼容性分级
zkEVM 的目标是让 zk 证明系统能证明以太坊虚拟机执行的正确性。按兼容性从强到弱分为四级:
| 类型 | 兼容性 | 证明开销 | 代表项目 |
|---|---|---|---|
| Type 1 | 完全等价于以太坊 | 极高(逐条 opcode 证明) | None fully(Scroll 的长期目标) |
| Type 2 | 完全等价 EVM,微小修改 | 很高 | Scroll |
| Type 3 | 大部分兼容,部分 gas/opcodes 调整 | 中高 | Polygon zkEVM |
| Type 4 | 高级语言级兼容,非字节码级 | 低 | zkSync Era、Starknet |
兼容性 vs 性能 的权衡:
Type 1-2: 以太坊字节码 → 电路逐条模拟 → EVM 等价 but 证明极慢
Type 3: Solidity → 部分翻译 → 大部分兼容,证明较快
Type 4: Solidity → 编译为 zk 友好的中间语言(Yul/zkASM)→ 最快但兼容性最低
4.2 Type 4 zkEVM 的工作流程
zkSync Era 的编译流程:
Solidity 源码
→ zkSync compiler (solc fork)
→ LLVM IR
→ zkASM(zkSync 汇编语言)
→ 字节码在 zkSync VM 上执行
→ 生成 zk-SNARK 证明
→ 提交到以太坊 L1 验证
// Type 4 zkEVM 中开发者需要注意的兼容差异
contract ZkEvmDifferences {
// 1. 某些 EVM 预编译合约不可用或不相同
// 如 KECCAK256 在 zkSync 中 gas 成本不同
// 2. 地址空间不同
// CREATE2 的地址计算有细微差别
// 3. 某些 opcode gas 成本不同
// SSTORE 在 zkSync 中可能更贵或更便宜
// 4. 原生不是 ETH,而是底层链的 token
// msg.value 的行为可能与 L1 不同
}
一句话总结:zkEVM 的 Type 1→4 分级暴露了密码学与性能的根本权衡——越接近原生 EVM 越难生成证明,越快生成证明偏离原生 EVM 越远,开发者的迁移成本就越高。
5. circom 电路编写与 snarkjs 验证
5.1 circom 基础语法
circom 是最流行的 zk-SNARK 电路描述语言。它的核心思想是约束系统(Constraint System)。
// 证明:我知道 a 和 b 使得 c = a * b,且 c 是公开输入
pragma circom 2.0.0;
template Multiplier() {
signal input a; // 私有输入
signal input b; // 私有输入
signal output c; // 公开输出
// 约束:c 必须等于 a * b
c <== a * b;
}
component main {public [c]} = Multiplier();
5.2 更复杂的电路:Merkle 成员证明
// 证明:我知道 secret 和 path,使得 hash(secret, path) 等于公开的 merkleRoot
pragma circom 2.0.0;
include "node_modules/circomlib/circuits/poseidon.circom";
include "node_modules/circomlib/circuits/mux1.circom";
template MerkleProof(levels) {
signal input leaf;
signal input pathElements[levels];
signal input pathIndices[levels]; // 0 = left, 1 = right
signal output root;
component hashers[levels];
component mux[levels];
signal currentHash[levels + 1];
currentHash[0] <== leaf;
for (var i = 0; i < levels; i++) {
hashers[i] = Poseidon(2);
mux[i] = MultiMux1(2);
// 根据 pathIndices 决定是 [current, sibling] 还是 [sibling, current]
mux[i].c[0][0] <== currentHash[i];
mux[i].c[0][1] <== pathElements[i];
mux[i].c[1][0] <== pathElements[i];
mux[i].c[1][1] <== currentHash[i];
mux[i].s <== pathIndices[i];
hashers[i].inputs[0] <== mux[i].out[0];
hashers[i].inputs[1] <== mux[i].out[1];
currentHash[i + 1] <== hashers[i].out;
}
root <== currentHash[levels];
}
component main {public [root]} = MerkleProof(20);
5.3 snarkjs 的完整工作流
# 1. 编译电路
circom merkle_proof.circom --r1cs --wasm --sym
# 2. 可信设置(PlonK 方案,仅需单阶段)
snarkjs plonk setup merkle_proof.r1cs powersOfTau28_hez_final_20.ptau merkle_proof.zkey
# 3. 导出验证密钥
snarkjs zkey export verificationkey merkle_proof.zkey verification_key.json
# 4. 生成证明(witness 从输入计算得出)
node merkle_proof_js/generate_witness.js merkle_proof_js/merkle_proof.wasm input.json witness.wtns
snarkjs plonk prove merkle_proof.zkey witness.wtns proof.json public.json
# 5. 验证证明
snarkjs plonk verify verification_key.json public.json proof.json
# 6. 生成 Solidity 验证合约
snarkjs zkey export solidityverifier merkle_proof.zkey verifier.sol
5.4 Solidity 验证合约
// 由 snarkjs 自动生成的 PlonK 验证合约(简化示意)
contract PlonkVerifier {
// 使用 bn128 预编译验证 PlonK 证明
function verifyProof(
bytes memory proof,
uint256[] memory publicSignals
) public view returns (bool) {
// 解析 proof 的各个元素
// 调用 pairing check 验证
// 验证公开输入的多项式求值
// ... 具体实现由 snarkjs 自动生成
return true;
}
}
一句话总结:circom + snarkjs 是 zk 开发者的入门工具链——circom 把算术约束写成了类编程语言的语法,snarkjs 将其编译为可证明、可验证的系统;理解约束思维(“所有运算都必须写成 R1CS 中的乘法门”)是写电路的第一课。
6. 隐私转账:Tornado Cash 的原理
6.1 核心实现:承诺与零知识揭示
Tornado Cash 是一个 zk 隐私混币协议,核心逻辑基于 Merkle Tree 成员证明:
// Tornado Cash 核心逻辑(概念演示,非完整代码)
contract TornadoCash {
// Merkle Tree 相关
uint256 public constant LEVELS = 20;
uint256 public constant MAX_AMOUNT = 2**LEVELS;
bytes32[LEVELS] public filledSubtrees;
bytes32 public root;
uint256 public nextIndex = 0;
mapping(bytes32 => bool) public nullifierHashes; // 防止双花
mapping(bytes32 => bool) public roots; // 历史根
IVerifier public verifier;
uint256 public denomination;
event Deposit(bytes32 indexed commitment, uint32 leafIndex, uint256 timestamp);
event Withdrawal(address to, bytes32 nullifierHash);
// 存入:用户提交一个 commitment = hash(secret, nullifier)
function deposit(bytes32 _commitment) external payable {
require(msg.value == denomination, "Wrong amount");
require(nextIndex < MAX_AMOUNT, "Full");
uint256 insertedIndex = nextIndex;
nextIndex++;
// 更新 Merkle Tree
bytes32 currentHash = _commitment;
for (uint256 i = 0; i < LEVELS; i++) {
if (insertedIndex % 2 == 0) {
filledSubtrees[i] = currentHash;
currentHash = hashLeftRight(currentHash, zeros(i));
} else {
currentHash = hashLeftRight(filledSubtrees[i], currentHash);
}
insertedIndex /= 2;
}
root = currentHash;
roots[root] = true;
emit Deposit(_commitment, uint32(nextIndex - 1), block.timestamp);
}
// 取出:证明"我知道某个 secret,其 commitment 在树中,且不重复提取"
function withdraw(
bytes calldata _proof,
bytes32 _root,
bytes32 _nullifierHash,
address _recipient
) external {
require(roots[_root], "Root not found");
require(!nullifierHashes[_nullifierHash], "Already spent");
// 构建公开输入:root、nullifierHash、recipient(可能哈希后)
uint256[] memory publicInputs = new uint256[](3);
publicInputs[0] = uint256(_root);
publicInputs[1] = uint256(_nullifierHash);
publicInputs[2] = uint256(uint160(_recipient));
require(verifier.verifyProof(_proof, publicInputs), "Invalid proof");
nullifierHashes[_nullifierHash] = true;
payable(_recipient).transfer(denomination);
emit Withdrawal(_recipient, _nullifierHash);
}
}
6.2 隐私性分析
存入阶段:
- 链上公开:commitment(无法反推 secret)
- 链下保留:secret + nullifier
取出阶段:
- 链上公开:proof(零知识,不泄露 secret)、root(群组成员?无法确定哪个)、nullifierHash(防双花但不泄露 secret)
- 无法关联:相同用户的存入和取出交易在链上无关联
局限:
- 金额固定(否则可通过金额关联)
- 需要足够多用户参与才有效( anonymity set)
- 链上时间戳、gas price 等 heuristics 仍可辅助分析
一句话总结:Tornado Cash 用"Merkle Tree 成员证明 + nullifier 防双花 + recipient 混排"的密码学组合,实现了链上可见但不可关联的隐私转账——它的安全性依赖于零知识性和 anonymity set 的大小。
7. zkRollup 证明生成与 Groth16 vs PlonK 对比
7.1 zkRollup 中的证明生成
zkRollup 批次证明流程:
批次交易 → 交易解析 → 状态迁移计算
↓
电路执行(每笔交易 → 电路约束 → witness)
↓
多项式编码(PlonK/Groth16)
↓
证明生成(prover,计算密集,耗时数秒到数分钟)
↓
证明提交到 L1(几百字节到几十 KB)
↓
L1 验证合约(毫秒级,常数时间)
7.2 Groth16 vs PlonK 对比
| 维度 | Groth16 | PlonK |
|---|---|---|
| 可信设置 | 每电路一次 | 通用(单次通用 setup,后接电路特定部分) |
| 证明大小 | ~192 字节(最小) | ~400 字节 |
| 验证时间 | ~1.5 ms | ~3 ms |
| 证明生成时间 | 较快 | 稍慢 |
| 递归友好 | 较难 | 较友好 |
| 自定义门 | 不支持 | 支持(custom gates 优化特定运算) |
| 代表项目 | Filecoin, Zcash | Aztec, Polygon zkEVM, Scroll |
// Groth16 验证器(典型实现)
function verifyGroth16(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[] memory input
) public view returns (bool) {
// e(A,B) · e(C,D) = e(E,F) 的配对检查
// 使用以太坊 0x08 预编译
}
// PlonK 验证器(典型实现)
function verifyPlonk(
bytes memory proof,
uint256[] memory publicInputs
) public view returns (bool) {
// 多轮多项式承诺检查
// lagrange 基求值 + KZG 承诺 + batched opening
}
一句话总结:Groth16 是验证最快、证明最小的王者,但每电路 setup 的成本让它在频繁迭代的项目中力不从心;PlonK 用通用可信设置和自定义门换来了更好的工程体验——zkRollup 项目的选择通常取决于自身对证明速度、验证成本和开发迭代频率的权衡。
8. 总结
- 零知识证明的本质:在不透露输入的前提下证明计算结果的正确性
- zk-SNARKs:简洁高效,需要可信设置,Groth16 是标杆
- zk-STARKs:无需信任假设,抗量子,证明较大,Starknet 代表
- zkEVM:Type 1-4 的兼容性光谱,是性能和迁移成本的权衡
- circom/snarkjs:zk 电路开发的入门工具链,约束思维是关键
- Tornado Cash:zk 隐私应用的教科书案例,证明了"可见但不可关联"的可能性
- zkRollup:zk 证明的最大规模化应用,用密码学替代了 Optimistic Rollup 的 7 天挑战窗口
相关阅读
延伸阅读
以下是一个完整的 circom 电路及其 Solidity 验证合约示例,演示如何使用零知识证明来验证"我知道两个数 secretA 和 secretB,它们的乘积等于公开值 publicProduct"。
// multiplier.circom
// 证明我知道 a 和 b 使得 a * b = c(c 公开)
pragma circom 2.0.0;
template Multiplier() {
signal input a;
signal input b;
signal output c;
c <== a * b;
}
component main {public [c]} = Multiplier();
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Groth16 验证合约(由 snarkjs 自动生成,简化后)
contract MultiplierVerifier {
// bn128 曲线基点和验证密钥常量
uint256 constant SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617;
struct VerifyingKey {
uint256[2] alpha1;
uint256[2][2] beta2;
uint256[2][2] gamma2;
uint256[2][2] delta2;
uint256[2][] IC;
}
VerifyingKey public vk;
constructor() {
// 初始化验证密钥(实际部署时替换为 setup 输出)
vk.alpha1 = [uint256(1), uint256(2)];
vk.beta2 = [[uint256(1), uint256(2)], [uint256(3), uint256(4)]];
vk.gamma2 = [[uint256(1), uint256(2)], [uint256(3), uint256(4)]];
vk.delta2 = [[uint256(1), uint256(2)], [uint256(3), uint256(4)]];
}
function verifyProof(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[1] memory input
) public view returns (bool) {
// 线性组合 IC
uint256[2] memory vk_x = vk.IC[0];
for (uint256 i = 0; i < input.length; i++) {
require(input[i] < SNARK_SCALAR_FIELD, "Input too large");
vk_x = _addG1(vk_x, _scalarMulG1(vk.IC[i + 1], input[i]));
}
// 配对检查:e(a, b) == e(vk_x, gamma2) * e(c, delta2) * e(alpha1, beta2)
bool result = _pairingCheck(a, b, vk_x, c);
return result;
}
// 以下为 G1/G2 算术和配对检查的占位实现
// 生产环境应使用 OpenZeppelin 的 Verifier 模板或 snarkjs 生成
function _addG1(uint256[2] memory p1, uint256[2] memory p2)
internal
pure
returns (uint256[2] memory)
{
// G1 点加法(简化示意,需完整 bn128 实现)
return [p1[0] + p2[0], p1[1] + p2[1]];
}
function _scalarMulG1(uint256[2] memory p, uint256 s)
internal
pure
returns (uint256[2] memory)
{
return [p[0] * s, p[1] * s];
}
function _pairingCheck(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory vk_x,
uint256[2] memory c
) internal view returns (bool) {
// 生产环境调用 evm 地址 0x08 的 bn128 pairing 预编译
// 这里为示意简化
uint256[24] memory input;
input[0] = a[0]; input[1] = a[1]; // A in G1
input[2] = b[0][0]; input[3] = b[0][1]; input[4] = b[1][0]; input[5] = b[1][1]; // B in G2
input[6] = vk_x[0]; input[7] = vk_x[1];
input[8] = vk.gamma2[0][0]; input[9] = vk.gamma2[0][1]; input[10] = vk.gamma2[1][0]; input[11] = vk.gamma2[1][1];
input[12] = c[0]; input[13] = c[1];
input[14] = vk.delta2[0][0]; input[15] = vk.delta2[0][1]; input[16] = vk.delta2[1][0]; input[17] = vk.delta2[1][1];
input[18] = vk.alpha1[0]; input[19] = vk.alpha1[1];
input[20] = vk.beta2[0][0]; input[21] = vk.beta2[0][1]; input[22] = vk.beta2[1][0]; input[23] = vk.beta2[1][1];
bool success;
uint256[1] memory output;
assembly {
success := staticcall(sub(gas(), 2000), 8, input, 0x300, output, 0x20)
}
return success && output[0] == 1;
}
}
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。