ZK 電路實作完全攻略:Noir 語言從入門到部署的完整代碼範例

本文以非正式、口語化的風格,手把手教學 Noir 語言的 ZK 電路開發。涵蓋環境架設、範圍證明、Merkle 樹驗證、知識簽章等實戰電路的完整代碼範例,深入探討約束數量優化、witness 函數使用、常見陷阱等進階議題,並提供部署到以太坊的實務操作指引。這是一篇理論與實作完美結合的 ZK 開發教程。

ZK 電路實作完全攻略:Noir 語言從入門到部署的完整代碼範例

概述

讀完一堆 ZK-SNARK 的理論教程,感覺自己懂了?但當你開 IDE 準備寫第一個電路的時候,是不是又懵了?理論和實作之間的鴻溝,比你想像的大得多。

這篇文章就是要幫你跨過這個鴻溝。我會用 Noir 這個由 Aztec 團隊開發的 ZK 電路語言,從零開始帶你寫電路。Noir 的語法比 Circom 友善很多,而且最近幾年生態發展得很快,越來越多項目開始用 Noir 開發隱私合約。

學完這篇,你應該能獨立寫出幾種常見的隱私電路:範圍證明、知識簽章、merkle 樹驗證,而且懂為什麼要這樣設計。讓我們開始吧。

第一章:Noir 環境折騰記

1.1 安裝 Rust 環境

Noir 是用 Rust 寫的,所以先得把 Rust 環境架好。這個過程說難不難,說簡單也挺折騰人的,特別是網路環境不太好的話。

# 先檢查有沒有裝過 Rust
rustc --version
cargo --version

# 沒有就裝一個
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 安裝過程會問你選項,選 1 或直接回車就行
# 預設安裝包含 stable 工具鏈

# 讓環境變數生效
source ~/.cargo/env

# 確認安裝成功
rustc --version
# 應該輸出:rustc 1.XX.X

中國的玩家可能需要配一下 crates.io 的鏡像,不然下依賴會慢到懷疑人生。編輯 ~/.cargo/config.toml

[source.crates-io]
replace-with = 'ustc'

[source.ustc]
registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"

1.2 安裝 Nargo

Nargo 是 Noir 的編譯器,類似於 Circom 的編譯器 circom。安裝方式有兩種:編譯原始碼或者用預編譯版本。

# 方式一:用預編譯版本(推薦,比較快)
curl -L https://raw.githubusercontent.com/AztecProtocol/aztec-packages/master/noir-compiler/scripts/download-native.inc.sh | bash

# 方式二:從原始碼編譯(適合想要折騰的人)
git clone https://github.com/noir-lang/noir.git
cd noir
cargo build --release --package nargo
cargo install --path nargo

# 驗證安裝
nargo --version
# 應該輸出:noir 1.x.x

我個人建議用預編譯版本,編譯 Rust 專案實在太吃時間了,沒有抽菸喝茶的習慣的話會很痛苦。

1.3 建立第一個專案

# 用 nargo 初始化專案
nargo new my_first_circuit
cd my_first_circuit

# 看目錄結構
tree .
# 應該長這樣:
# .
# ├── Nargo.toml
# └── src
#     └── main.nr

# Nargo.toml 是專案設定檔
cat Nargo.toml

Nargo.toml 的內容大概長這樣:

[package]
name = "my_first_circuit"
type = "lib"
compiler_version = "1.0"

[dependencies]

第二章:寫電路前的心理準備

2.1 Noir 的語法哲學

Noir 的設計目標是讓ZK電路寫起來像寫普通程式。跟 Circom 比起來,Noir 的語法更接近 Rust,但又沒有 Rust 那麼嚴格。

// 這是 Noir 代碼,不是 Rust
fn main(x: Field, y: Field) -> pub Field {
    x + y
}

注意到 pub Field 前面那個 pub 關鍵字沒?這就是 Noir 用來區分公開輸入和私有輸入的方式。公開的放 pub,私有的就直接用變數名。

2.2 基本資料型別

Noir 有幾個內建的基本型別:

// Field: Noir 的核心型別,可以裝任意大的整數
// 在實作上等同於 BN254 曲線的標量域
let a: Field = 123456789;

// u8, u16, u32, u64: 有符號整數(注意:Noir 目前沒有無符號整數)
let b: u8 = 255;
let c: u32 = 4294967295;

// bool: 布林值,電路約束最終都會變成這個
let flag: bool = true;

// 整數之間可以轉換,但要小心溢位
let d: u32 = a as u32;

// 整數不能直接轉 Field,要用 .into()
let e: Field = b.into();

這裡有個坑很多人會踩:u8 最大只能裝 255,如果你想裝更大的數,得用 Field。很多新手會在這裡卡很久。

2.3 我的第一個 Noir 電路

讓我們從最簡單的開始:證明我知道兩個數的和等於某個值,但不透露這兩個數本身。

// src/main.nr

// 這是最簡單的 Noir 電路
fn main(
    // 私有輸入:兩個數
    x: Field,
    y: Field,
    // 公開輸入:預期的和
    sum: pub Field
) {
    // 約束:x + y 必須等於 sum
    // 如果不相等,證明會失敗
    assert(x + y == sum);
}

就是這麼簡單。assert 是 Noir 用來產生約束的關鍵字。當你執行這個電路的時候:

  1. Prover(證明者)輸入 x、y、sum
  2. 如果 x + y == sum,系統會產生一個證明
  3. Verifier(驗證者)只看到 sum 和 proof,就能確認「有人知道兩個數的和是 sum」

現在讓我們把這個電路跑起來:

# 編譯電路
nargo compile

# 應該輸出:
# [noir_commander] Circuit built successfully
# [noir_commander] Constraint system dumped to ./target/circuit.json

第三章:讓電路有點用:範圍證明

3.1 為什麼需要範圍證明

想像這個場景:我想證明我的年齡大於 18,但不透露具體年齡。

直接把年齡跟 18 比大小不行嗎?

// 這個電路有問題!
fn prove_age_private_bad(age: Field, min_age: Field) {
    assert(age > min_age);
}

問題在於區塊鏈上的約束是確定的。攻擊者可以枚舉所有可能的 age 值(19, 20, 21...),逐個試驗,直到找到讓約束成立的那個。這個攻擊叫 brute force witness enumeration,在密碼學論文裡很常見。

正確的做法是用範圍證明:我不是比較 age 和 18,而是證明 age 在某個範圍內。

3.2 範圍證明的數學原理

範圍證明的核心思想是:如果 a 在 [0, 2^n) 範圍內,那麼 a 可以用 n 個 bit 表示。

a = b_0 * 2^0 + b_1 * 2^1 + ... + b_{n-1} * 2^{n-1}

其中每個 b_i ∈ {0, 1}

所以要證明 age ∈ [0, 255],只要證明 age 可以用 8 個 bit 表示:

// src/range_proof.nr

// 範圍證明:證明 x 在 [0, 2^bits) 範圍內
fn prove_in_range(x: Field, bits: comptime u32) {
    // 變數用来存放每一位的結果
    let mut bits_assigned = [0 as u8; 64];
    
    // 临时變數
    let mut temp = x;
    let mut power_of_two = 1;
    
    // 逐位分解
    for i in 0..64 {
        if i < bits {
            bits_assigned[i] = (temp as u8) & 1;
            temp = (temp - bits_assigned[i] as Field) / 2;
            power_of_two = power_of_two * 2;
        }
    }
    
    // 重構:確保每個 bit 都是 0 或 1
    // 這裡的約束會確保 bit decomposition 是正確的
    let mut reconstructed = 0;
    power_of_two = 1;
    
    for i in 0..64 {
        if i < bits {
            // 約束:bit 必須是 0 或 1
            // 這個等式只在 bit ∈ {0,1} 時成立
            assert(bits_assigned[i] * bits_assigned[i] == bits_assigned[i]);
            
            reconstructed = reconstructed + bits_assigned[i] as Field * power_of_two;
            power_of_two = power_of_two * 2;
        }
    }
    
    // 最終約束:重構的值必須等於原始值
    assert(reconstructed == x);
}

// 用這個來證明年齡
fn prove_age_above_18(age: Field) -> pub Field {
    // 先確保 age 在 0-255 範圍內
    prove_in_range(age, 8);
    
    // 然後我們需要確保 age >= 18
    // 18 的二進制是 10010,所以 age 的最低 5 位必須能表示 >= 18
    
    // 這個函數檢查 age 是否 >= 18
    let min_value: Field = 18;
    assert(age >= min_value);
    
    age
}

3.3 使用標準庫簡化開發

自己寫 bit decomposition 挺麻煩的,Noir 的標準庫已經幫你封裝好了:

// 引入標準庫
use dep::std;

// 簡化版的範圍證明
fn prove_age_above_18_simple(age: Field) -> pub Field {
    // 確保 age <= 255
    std::collections::bytes::to_le_bits(age, 8);
    
    // 確保 age >= 18
    assert(age >= 18);
    
    age
}

不過我建議還是理解底層原理比較好,標準庫有時候會更新,文檔又不夠詳細,出問題的時候不懂原理根本無法 debug。

第四章:實戰: Merkle 樹驗證電路

4.1 為什麼 Merkle 驗證重要

Merkle 樹驗證是以太坊和大多數區塊鏈的基礎。在 ZK 應用中更是常見:

想像這個場景:我想證明我的帳戶餘額大於 1000,但帳戶資料存在 Merkle 樹的某個葉子。我不想透露是哪個葉子,也不想透露具體餘額。

這就需要 Merkle 樹驗證電路。

4.2 Merkle 樹基礎回顧

                    Root (32 bytes)
                   /              \
            Node1 (32 bytes)   Node2 (32 bytes)
            /         \         /         \
        Leaf0      Leaf1    Leaf2        Leaf3
        (hash)    (hash)   (hash)       (hash)

驗證流程:

  1. 已知 Root、Leaf、Path(路徑上的所有兄弟節點)
  2. 從 Leaf 開始,一路往上 hash
  3. 如果最終結果等於 Root,則 Leaf 在樹中

4.3 Noir 實現

// src/merkle_proof.nr

use dep::std;

// 計算兩個節點的父節點 hash
// node1 和 node2 都是 32 bytes
fn hash_pair(left: [u8; 32], right: [u8; 32]) -> [u8; 32] {
    // Noir 的 hash 函數封裝
    // 這裡使用 Poseidon 或 Keccak,具體取決於你的應用
    std::hash::keccak256([left, right].join(), 64)
}

// 驗證 Merkle 證明
// root: 公開的 Merkle 根
// leaf: 私有的葉子節點
// path: 私有的 Merkle 路徑(從葉子到根的兄弟節點列表)
// index: 公開的路徑索引(0 表示葉子在左側,1 表示在右側)
fn verify_merkle_path(
    root: pub [u8; 32],
    leaf: Field,
    path: [Field; 32],  // 假設樹深度為 32
    index: pub u32
) {
    // 將 leaf 轉換為 bytes
    let leaf_bytes = leaf.to_le_bytes(32);
    
    // 初始值為葉子
    let mut current_hash = leaf_bytes;
    
    // 從底部往上 hash
    for i in 0..32 {
        let path_element = path[i].to_le_bytes(32);
        
        // 根據 index 的第 i 位決定左右順序
        let bit = (index >> i) & 1;
        
        if bit == 0 {
            // 當前節點在左側
            current_hash = hash_pair(current_hash, path_element);
        } else {
            // 當前節點在右側
            current_hash = hash_pair(path_element, current_hash);
        }
    }
    
    // 最終結果必須等於 root
    assert(current_hash == root);
}

4.4 整合到應用

這是 Tornado Cash 風格的匿名轉帳電路:

// src/tornado_cash_style.nr

use dep::std;
use dep::merkle::verify_merkle_path;

// commitment 是 leaf 的 hash
// nullifier 是用來標識花費的 secret
fn anonymous_deposit(
    // 公開輸入
    commitment: pub Field,
    root: pub [u8; 32],
    
    // 私有輸入
    secret: Field,
    nullifier: Field,
    path: [Field; 32],
    path_index: u32
) {
    // 1. 驗證 commitment 是由 secret 和 nullifier 計算出來的
    let computed_commitment = std::hash::poseidon([secret, nullifier]);
    assert(computed_commitment == commitment);
    
    // 2. 驗證 commitment 在 Merkle 樹中
    verify_merkle_path(root, commitment, path, path_index);
    
    // 3. 計算 nullifier hash(用於鏈上追蹤)
    let nullifier_hash = std::hash::poseidon([nullifier]);
    
    // 這個值會被公開,讓其他人可以驗證這筆存款被花了
    // 但沒人知道是哪筆存款
}

這就是 Tornado Cash 的核心原理:存款時你公開 commitment(葉子節點的 hash),取款時你只公開 nullifier hash。別人只知道「有人取了這筆錢」,但不知道是誰。

第五章:知識簽章電路

5.1 什麼是知識簽章

知識簽章(Proof of Knowledge Signature)是一種特殊的零知識證明:我想證明「我知道某個私鑰對應的簽章」,但不透露私鑰本身。

在以太坊的應用場景裡常見於:證明我有權限操作某個帳戶,但不需要簽名(因為某些情况下即時簽名代價太高)。

5.2 Schnorr 簽章回顧

Schnorr 簽章比 ECDSA 簡單很多,更容易在電路中實現。

金鑰生成

簽名

驗證

5.3 Noir 實現

// src/schnorr_proof.nr

use dep::std;

// 驗證 Schnorr 簽章
// pubkey: 公開金鑰 (x 座標)
// signature_s: 簽名的 s 值
// message_hash: 消息的 hash
// R_point: 隨機點的 x 座標
fn verify_schnorr(
    // 公開輸入
    pubkey_x: pub Field,
    signature_s: pub Field,
    message_hash: pub Field,
    R_x: pub Field,
    
    // 私有輸入(這些永遠不會被公開)
    private_key: Field,
    randomness_k: Field
) {
    // 1. 驗證私鑰和公鑰的一致性
    // P = x * G,其中 G 是生成點
    let expected_pubkey = std::curve::bn254::fixed_base_scalar_mul(private_key);
    assert(expected_pubkey[0] == pubkey_x);
    
    // 2. 驗證隨機點的一致性
    // R = k * G
    let expected_R = std::curve::bn254::fixed_base_scalar_mul(randomness_k);
    assert(expected_R[0] == R_x);
    
    // 3. 計算挑戰值
    // e = H(R_x || message_hash)
    let e = std::hash::poseidon([R_x, message_hash]);
    
    // 4. 驗證簽名公式
    // s * G = R + e * P
    // 左邊:s * G
    let left = std::curve::bn254::fixed_base_scalar_mul(signature_s);
    
    // 右邊:R + e * P = k * G + e * x * G
    let e_times_pubkey = std::curve::bn254::fixed_base_scalar_mul(e * private_key);
    let right = std::curve::bn254::point_add(expected_R, e_times_pubkey);
    
    // 約束:左右兩邊必須相等
    assert(left[0] == right[0]);
    assert(left[1] == right[1]);
}

這個電路看起來有點長,但每一步都有明確的密碼學意義。核心思想就是:把所有公開可驗證的計算都變成約束。

5.4 實際使用場景

// 應用:免簽名的授權證明
fn authorize_without_signature(
    contract_address: pub Field,
    action: pub Field,
    authorized_pubkey: pub Field,
    
    // 證明者才知道的東西
    authorization_secret: Field,
    schnorr_randomness: Field
) {
    // 先驗證 Schnorr 簽章
    let message = std::hash::poseidon([contract_address, action]);
    verify_schnorr(
        authorized_pubkey,
        // 這裡假設有預計算好的簽章
        // 實際應用中可能需要另一個電路來生成這個簽章
        ...
    );
    
    // 然後驗證 authorization_secret 和 authorized_pubkey 的關係
    let derived_pubkey = std::curve::bn254::fixed_base_scalar_mul(authorization_secret);
    assert(derived_pubkey[0] == authorized_pubkey);
}

第六章:效能優化與常見陷阱

6.1 約束數量優化

約束數量直接影響 proving time 和驗證成本。一個好的電路設計應該:

避免不必要的約束

// 壞例子:比較兩個數的大小
fn compare_bad(a: Field, b: Field) -> Field {
    if a > b { 1 } else { 0 }
    // 問題:if-else 在 Noir 中可能產生額外的約束
}

// 好例子:直接用約束表示
fn compare_good(a: Field, b: Field) -> Field {
    // 約束:(a - b) * result == 0
    // 這意味著 result == 0 或 a == b
    // 結合另一個約束確保結果是二進制的
    let result = (a - b) / (a - b);  // 這行有問題
    
    // 正確做法是用 is_zero helper
    let diff = a - b;
    let is_zero = std::field::is_zero(diff);
    let is_positive = 1 - is_zero;
    
    // 這只是思路,具體實現要看 Noir 版本
}

合併相似約束

// 壞例子:三個獨立的範圍證明
prove_in_range(x, 8);
prove_in_range(y, 8);
prove_in_range(z, 8);

// 好例子:批次處理
fn prove_batch_in_range(x: Field, y: Field, z: Field) {
    // 把三個數打包成一個大的 Field,然後一次證明
    let combined = x + y * 256 + z * 256 * 256;
    prove_in_range(combined, 24);
}

6.2 witness 函數的正確用法

Noir 的函數分兩種:fnunconstrained fn

fn 會產生約束,unconstrained fn 不會。

// 這個函數會被嚴格約束
fn constrained_mul(a: Field, b: Field) -> Field {
    a * b  // 這是一個約束
}

// 這個函數不會產生約束,適合純計算
unconstrained fn unconstrained_mul(a: Field, b: Field) -> Field {
    a * b  // 這只是普通乘法計算
}

很多新手會在 fn 裡面呼叫複雜的輔助函數,導致約束數量暴增。合理使用 unconstrained fn 可以大幅提升效能。

6.3 常見錯誤集合

錯誤一:除零問題

// 錯誤代碼
fn divide_bad(a: Field, b: Field) -> Field {
    a / b  // b 可能是 0!
}

// 正確代碼
fn divide_good(a: Field, b: Field) -> Field {
    assert(b != 0);
    a / b
}

錯誤二:整數溢位

// 錯誤代碼:a 和 b 都是 u8,但相加可能超出 255
fn add_bad(a: u8, b: u8) -> u8 {
    a + b  // 可能溢位!
}

// 正確代碼:用 Field 或者手動檢查範圍
fn add_good(a: u8, b: u8) -> Field {
    assert(a as Field + b as Field <= 255 as Field);
    a + b
}

錯誤三:不理解 witness 的值

// 這個函數看起來會失敗,但實際上不會
fn misunderstand_witness(x: Field) {
    // 這不是約束!這只是聲明
    let is_large = x > 100;
    
    // 必須這樣寫才能產生約束
    assert(x > 100);
}

第七章:部署到以太坊

7.1 生成驗證合約

Noir 可以直接生成 Solidity 驗證合約,太神了。

# 編譯並生成驗證合約
nargo codegen verifier

# 這會在 contracts/ 目錄下生成 Solidity 合約
ls contracts/
# 應該有:plonk_vk.sol 或 groth16_vk.sol

7.2 部署到本地測試網

# 用 Foundry 測試
forge create --rpc-url http://localhost:8545 \
    --private-key $PRIVATE_KEY \
    src/Verifier.sol:TurboVerifier

# 確認部署成功
cast call $VERIFIER_ADDRESS "verify(uint256[24],uint256[2])" \
    --rpc-url http://localhost:8545 \
    $PROOF \
    $INPUTS

7.3 前端集成

// 使用 @aztec/barretenberg.js 產生證明
const { compute_witness, proof } = await client.excecute(
    'my_first_circuit',
    {
        x: 10,
        y: 20,
        sum: 30
    }
);

// proof 就是可以用來驗證的零知識證明
console.log('Proof generated:', proof);

結語:ZK 電路開發的一點心得

折騰 ZK 電路開發有一段時間了,有幾點感想想分享:

第一,理論和實作真的不是一回事。看論文覺得懂了,動手寫才發現處處是坑。特別是約束系統的設計,一個小失誤可能導致整個電路不安全。

第二,Noir 社群雖然比 Circom 小,但這幾年發展很快。文件還是有點散,有些 edge case 要靠 GitHub issues 和 Discord 解決。

第三,效能優化是永無止境的。一個電路從功能正確到可以實際部署,可能需要好幾輪的優化。我的建議是先讓它 work,再讓它 fast。

第四,安全性永遠是第一位的。ZK 電路的安全假設很脆弱,一個小小的約束漏洞可能導致整個系統被攻破。如果你的電路要處理真實資產,找專業的審計團隊很重要。

ZK 這個領域機會很多,坑也很多。想入坑的朋友建議從簡單的電路開始,慢慢積累經驗。祝大家學習順利!

標籤

zk-snark, noir, circom, zero-knowledge-proof, circuit, privacy, ethereum, zkrollup, smart-contract, cryptography, tutorial, implementation, solidity

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。

目前尚無評論,成為第一個發表評論的人吧!