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 用來產生約束的關鍵字。當你執行這個電路的時候:
- Prover(證明者)輸入 x、y、sum
- 如果 x + y == sum,系統會產生一個證明
- 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 應用中更是常見:
- Tornado Cash 的存款證明
- zkEVM 的狀態驗證
- 匿名身份系統
想像這個場景:我想證明我的帳戶餘額大於 1000,但帳戶資料存在 Merkle 樹的某個葉子。我不想透露是哪個葉子,也不想透露具體餘額。
這就需要 Merkle 樹驗證電路。
4.2 Merkle 樹基礎回顧
Root (32 bytes)
/ \
Node1 (32 bytes) Node2 (32 bytes)
/ \ / \
Leaf0 Leaf1 Leaf2 Leaf3
(hash) (hash) (hash) (hash)
驗證流程:
- 已知 Root、Leaf、Path(路徑上的所有兄弟節點)
- 從 Leaf 開始,一路往上 hash
- 如果最終結果等於 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 簡單很多,更容易在電路中實現。
金鑰生成:
- 選擇私鑰
x - 計算公鑰
P = x * G
簽名:
- 選擇隨機數
k - 計算
R = k * G - 計算挑戰
e = H(R || message) - 計算回應
s = k + e * x
驗證:
- 檢查
s * G == R + e * P
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 的函數分兩種:fn 和 unconstrained 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
相關文章
- 以太坊隱私技術實作教學完整指南:Aztec、Railgun、Privacy Pools 程式碼範例與深度技術分析(2025-2026) — 本文深入探討以太坊三大主流隱私技術——Aztec Network、Railgun 與 Privacy Pools——的實作細節,提供可直接部署的程式碼範例與技術分析。涵蓋各協議的核心智慧合約架構、零知識證明電路設計、SDK 整合方式、以及真實攻擊案例與防護策略。我們提供完整的 Noir 語言範例、TypeScript SDK 使用指南、Solidity 智慧合約代碼,以及前端 React 整合範例。
- 零知識證明數學推導完整指南:從密碼學基礎到以太坊應用實戰 — 本文從數學推導的角度,全面分析零知識證明的基本原理、主要類型(SNARK、STARK、Bulletproofs)、電路設計方法,以及在以太坊上的實際應用部署。涵蓋完整的代數推導、Groth16 和 Plonkish 約束系統、FRI 協議、以及 zkEVM 架構分析。詳細比較不同 ZK 系統的 Gas 消耗與 TPS 表現,提供量化數據支撐的事實依據。
- 零知識證明電路設計與開發完整指南:從 Circom 到 Noir 與 Halo2 實作教學 — 本篇文章提供從理論到實作的完整 ZK 電路開發指南,涵蓋 Circom、Noir 與 Halo2 三種主流電路開發框架。深入探討 Merkle 驗證電路、範圍證明、簽章驗證電路等常見模式的設計與實現,同時分析 Halo2 與 PLONK 的數學推導差異,並討論 ZK-Friendly Smart Contract 開發的安全注意事項。提供完整的 Circom/Noir/Halo2 程式碼範例。
- 零知識電路開發完整教學:從理論基礎到智能合約整合的開發者路徑 — 本文提供從電路設計基礎到實際智能合約整合的完整開發者路徑。涵蓋:零知識證明的形式化定義與代數電路視角、Circom 開發環境架設與工具鏈配置、常見電路設計模式(比較器、範圍證明、Merkle 樹驗證)、複雜電路設計原則與調試技巧、從電路到 Solidity 驗證合約的完整工作流、信任設置(Powers of Tau)詳解、以及 NOIR 和 Halo2 等新一代工具的入門介紹。提供完整的代碼範例和開發實踐指導。
- Privacy Pool ZK-Proof 驗證合約完整實作指南:從電路設計到 Solidity 部署 — 本文深入探討 Privacy Pool 系統中零知識證明(ZKP)驗證合約的完整實作流程。我們從密碼學基礎出發,詳細解釋 Groth16 和 PLONK 兩種主流零知識證明系統的原理,提供完整的 Circom 電路代碼範例,並展示如何將這些電路部署到以太坊區塊鏈上進行驗證。涵蓋 Merkle 樹驗證電路、承諾方案實現、完整隱私池合約代碼、以及可信設置教學。
延伸閱讀與來源
- zkSNARKs 論文 Gro16 ZK-SNARK 論文
- ZK-STARKs 論文 STARK 論文,透明化零知識證明
- Aztec Network ZK Rollup 隱私協議
- Railgun System 跨鏈隱私協議
這篇文章對您有幫助嗎?
請告訴我們如何改進:
評論
發表評論
注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。
目前尚無評論,成為第一個發表評論的人吧!