零知识证明:zk-SNARKs、zk-STARKs、电路与隐私应用

从零知识证明的直观理解出发,深入剖析 zk-SNARKs 可信设置与递归证明、zk-STARKs 无需信任的优势,系统讲解 zkEVM 四类兼容性分级、circom 电路编写、Tornado Cash 隐私转账与 zkRollup 证明生成机制。

导语:证明你知道某一个秘密,却不泄露秘密本身

零知识证明(Zero-Knowledge Proof,ZKP)是密码学中最反直觉、也最强大的工具之一。想象一个场景:你向朋友证明你知道某个仓库的密码,但你不需要把密码告诉对方——你可以直接走进去把仓库里的一块石头拿出来,以此证明你确实知道密码。

在区块链领域,零知识证明解决了两个核心问题:

  1. 可扩展性(Scalability):链下执行计算,链上只验证一个简洁的证明
  2. 隐私性(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-SNARKszk-STARKs
可信设置需要(部分系统已免)不需要(完全透明)
证明大小~200 字节数十 KB ~ 数百 KB
验证成本低(单次配对数个毫秒)较高(多次哈希运算)
证明生成速度较慢(需电路优化)较快,可大规模并行
抗量子计算部分不是(依赖椭圆曲线)是(仅依赖哈希函数)
代表项目Zcash、zkSyncStarknet

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 对比

维度Groth16PlonK
可信设置每电路一次通用(单次通用 setup,后接电路特定部分)
证明大小~192 字节(最小)~400 字节
验证时间~1.5 ms~3 ms
证明生成时间较快稍慢
递归友好较难较友好
自定义门不支持支持(custom gates 优化特定运算)
代表项目Filecoin, ZcashAztec, 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. 总结

  1. 零知识证明的本质:在不透露输入的前提下证明计算结果的正确性
  2. zk-SNARKs:简洁高效,需要可信设置,Groth16 是标杆
  3. zk-STARKs:无需信任假设,抗量子,证明较大,Starknet 代表
  4. zkEVM:Type 1-4 的兼容性光谱,是性能和迁移成本的权衡
  5. circom/snarkjs:zk 电路开发的入门工具链,约束思维是关键
  6. Tornado Cash:zk 隐私应用的教科书案例,证明了"可见但不可关联"的可能性
  7. 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;
    }
}

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「blockchain」更多文章

  1. Rollup 序列器、L3 与应用链
  2. 账户抽象:ERC-4337、智能合约钱包与 Paymaster
  3. 模块化区块链与数据可用性层:Celestia、EigenDA、Avail 与 Rollup 的 DA 选型