為什麼證明需要的是方程式,而不是程式碼
在上一篇你認識了零知識證明的三個承諾——完備性、健全性與零知識——也看到了Fiat-Shamir技巧如何把互動式證明變成一次性的非互動證明。但那套優美的理論與一支真實的程式之間,存在一道鴻溝:證明系統沒辦法「替你跑 Python」。在它底層,所有機制——多項式、承諾、隨機挑戰——其實只說一種語言:有限體上的算術,也就是對一個大質數取模後的 `+` 與 `×` 兩種運算。
因此每一套 ZK 系統最先要做的,就是翻譯:把「我正確地執行了這段運算」這句話,改寫成「這一組代數方程式全都成立」。這個翻譯過程稱為算術化(arithmetization),它不起眼,卻是整座大樓的承重牆。如果這些方程式沒能忠實地捕捉那段運算,後面再厲害的密碼學都救不了你。
本文會走過幾乎每個證明都會經過的管線的前三站:程式 → 算術電路 → 秩一約束系統(R1CS) →(多項式形式)。我們會剛好停在密碼學魔法之前——也就是把方程式壓成一個極小證明的多項式承諾與可信設定,因為那整套正是下一篇的主題。讀完之後,你看著幾行程式碼,就能看見藏在裡頭的那些方程式。
算術電路:在一個體上,只剩加與乘
算術電路是一張由閘組成的有向圖,其中每個閘不是加法閘就是乘法閘,而電線上傳遞的是數字。就這樣——沒有 `if`、沒有迴圈、沒有字串、沒有比較。一個 ZK 電路正是這種東西:你把一個個 `+` 與 `×` 閘接起來,讓輸出線的值等於你想證明某件事的那個量。原則上,任何電腦能做的運算,都能被展開成這樣一張圖。
這些數字並不是一般的整數——它們是有限體裡的元素:對某個大質數 `p`,每個值都落在 `0, 1, …, p−1` 之間,而所有算術都對 `p` 取模。對適合 SNARK 的曲線而言,`p` 大約是 254 位元。在體裡運算,正是讓後續多項式數學成立的關鍵:減法、甚至除法都有良好定義(每個非零元素都有乘法反元素),值永遠不會溢位,也沒有浮點取整的意外。代價是:體完全不懂 `<`、整數除法或位元運算——這些我們稍後得親手重建。
接下來有一個關鍵的簡化,讓整個方案變得可處理:在這個世界裡,加法幾乎免費,真正「要付代價」的是每一次乘法。你稍後會看到,加法與「乘上常數」都會融進下一步的線性帳目裡,而每一次「兩條未知電線相乘」都會換來一條自己的方程式。所以當工程師說某個電路有「四百萬條約束」時,他們大致是指它有四百萬次非平凡的乘法。
把程式攤平成一個個單一閘
來看一個具體的主張:「我知道一個祕密 `x`,使得 `x³ + x + 5 = 35`。」(答案是 `x = 3`,但驗證者永遠不該知道這件事。)你沒辦法把 `x*x*x + x + 5` 這種多運算的式子直接餵給電路——一個閘只能做剛好一次運算。所以你得把它攤平:把運算改寫成每一行只做一次 `+` 或 `×`,並剛好引入一條新的中間電線。這跟編譯器的三位址/SSA 形式是同一個想法。
# Statement: "I know x such that x^3 + x + 5 == 35"
#
# Original expression (too complex for a single gate):
# out = x*x*x + x + 5
#
# Flattened — every line is ONE multiply or ONE add,
# and each introduces exactly one new wire:
sym1 = x * x # gate 1 (multiply) -> x^2
y = sym1 * x # gate 2 (multiply) -> x^3
sym2 = y + x # gate 3 (add)
out = sym2 + 5 # gate 4 (add a constant)
# Public (the verifier sees this): out = 35
# Private (the prover's secret): x = 3
# Intermediate wires the prover must also fill in: sym1, y, sym2現在這段運算成了一串平坦的四個閘,作用在五條工作電線上(`x`、`sym1`、`y`、`sym2`、`out`)。注意祕密 `x` 只是其中一條線;其餘的都是證明者在抵達答案途中算出來的衍生值。證明者替所有這些電線——公開的、私密的、中間的——所填入的完整數值集合,我們稱之為見證(witness),它正是接下來兩節的核心。
R1CS:每次乘法一條方程式
秩一約束系統(R1CS)把整段攤平後的程式表達成一串約束,每一條都有固定的形狀 (A · s) × (B · s) = (C · s)。這裡的 `s` 是見證向量——把每一條工作電線疊成一份清單,並在最前面放一個常數 `1`,好讓我們能表達加法與常數。每條約束都配有三列 `A`、`B`、`C`,各自從見證中挑出一個線性組合。之所以叫秩一,是因為左邊那兩個因子各自都只是一個線性組合——唯一允許的非線性,就是中間那一次乘法。
我們把見證排成 `s = [ 1, x, out, sym1, y, sym2 ]`。我們的四個閘各自恰好變成一條約束。請看那兩個加法(閘 3 與閘 4)如何融進線性的 `A` 列、根本不需要自己的乘法——這就是「加法免費」那句話的具體展現。
Witness order: s = [ 1, x, out, sym1, y, sym2 ] For x = 3: s = [ 1, 3, 35, 9, 27, 30 ] Gate 1: x * x = sym1 A1 = [0, 1, 0, 0, 0, 0] A1·s = 3 B1 = [0, 1, 0, 0, 0, 0] B1·s = 3 C1 = [0, 0, 0, 1, 0, 0] C1·s = 9 check: 3 * 3 = 9 OK Gate 2: sym1 * x = y A2 = [0, 0, 0, 1, 0, 0] A2·s = 9 B2 = [0, 1, 0, 0, 0, 0] B2·s = 3 C2 = [0, 0, 0, 0, 1, 0] C2·s = 27 check: 9 * 3 = 27 OK Gate 3: (x + y) * 1 = sym2 <-- an ADD: both addends go in A, B = 1 A3 = [0, 1, 0, 0, 1, 0] A3·s = 30 B3 = [1, 0, 0, 0, 0, 0] B3·s = 1 C3 = [0, 0, 0, 0, 0, 1] C3·s = 30 check: 30 * 1 = 30 OK Gate 4: (5 + sym2) * 1 = out <-- constant 5 rides on the leading 1 A4 = [5, 0, 0, 0, 0, 1] A4·s = 35 B4 = [1, 0, 0, 0, 0, 0] B4·s = 1 C4 = [0, 0, 1, 0, 0, 0] C4·s = 35 check: 35 * 1 = 35 OK
現在檢查整個證明主張變得機械化:一個見證為有效,當且僅當每一條約束都成立。沒有執行、沒有直譯器——只剩幾個內積與乘法。
def r1cs_satisfied(A, B, C, s):
for i in range(num_constraints):
left = dot(A[i], s)
right = dot(B[i], s)
out = dot(C[i], s)
if (left * right) % p != out % p:
return False # gate i is violated -> witness is invalid
return True # every gate holds -> s is a valid witness見證:公開、私密,以及其間的一切
見證 `s` 是對每一條電線完整的賦值,它天生分成三部分:常數 `1`;驗證者被允許看見的公開輸入與輸出(這裡是 `out = 35`);以及私密部分——祕密輸入 `x` 加上每一條中間電線(`sym1`、`y`、`sym2`)。證明者知道整個 `s`;驗證者只知道公開的那一片與證明本身。這道切分正是重點所在:下一篇疊上去的零知識,讓證明者能說服驗證者「存在某個與公開值相符的有效 `s`」,卻不洩漏私密部分。R1CS 是骨架,零知識則是拉在它前面的那道布幕。
現在來看開頭答應過的那個小例子。假設公開數字是 `n = 15`,而你想證明「我知道 n 的兩個因數」卻不揭露它們。攤平之後,那只是一次乘法,所以這個 R1CS 恰好只有一條約束:`a × b = n`。在直接編譯成 R1CS 的電路 DSL「Circom」裡,它長這樣:
pragma circom 2.1.0;
// Prove: "I know two factors of the public number n."
template Factoring() {
signal input a; // private witness
signal input b; // private witness
signal output n; // public output
// '<==' both ASSIGNS n = a*b AND emits the R1CS
// constraint a * b = n (one rank-1 multiplication).
n <== a * b;
}
component main = Factoring();
// Public value the verifier checks: n = 15
// Private witness the prover supplies: a = 3, b = 5
// The proof reveals that SOME a, b multiply to 15 -- not which ones.從 R1CS 到單一的多項式檢查
對我們而言,一條一條檢查 `m` 個約束沒問題,但對證明系統來說很笨拙——我們希望驗證者不論電路多大,都能用大致常數的工作量確認整段執行。橋樑就是把所有約束打包成多項式。在體裡挑 `m` 個相異的取值點 `r₁, …, r_m`,每條約束一個。對每個見證槽位 `j`,`A` 矩陣跨所有約束的那一行,可由內插定義出一個多項式 `Aⱼ(x)`,使得 `Aⱼ(rᵢ)` 等於矩陣項 `A[i][j]`。對 `B` 與 `C` 也照辦。這層打包就是二次算術程式(QAP)。
接著把見證合進來:定義 `A(x) = Σⱼ sⱼ · Aⱼ(x)`,`B(x)` 與 `C(x)` 同理。重點來了:在每個點 `rᵢ` 取值,恰好會還原出 `(Aᵢ·s)`、`(Bᵢ·s)`、`(Cᵢ·s)`——所以「`m` 條約束全成立」就等於說多項式 `P(x) = A(x)·B(x) − C(x)` 在每個 `rᵢ` 上都為零。而一個在 `r₁, …, r_m` 處全部歸零的多項式,恰恰就是能被標的多項式 `Z(x) = (x − r₁)(x − r₂)…(x − r_m)` 整除的多項式。
於是整段執行收斂成一句乾淨的代數陳述:存在一個商多項式 `H(x)`,使得 `A(x)·B(x) − C(x) = H(x)·Z(x)`。證明者知道見證,建出 `A, B, C, H`,接著必須說服驗證者這唯一的整除關係成立。這正是通往 zk-SNARK 的門:證明者對這些多項式做出承諾,並在一個隨機點(透過 Fiat-Shamir 選出)證明該恆等式成立——因為兩個次數有界的相異多項式只會在極少數點上吻合,所以在隨機一點看似巧合的相等,其實是「該恆等式處處為真」的壓倒性證據。讓這一切既簡潔又能隱藏祕密的承諾方案與可信設定,正好就是下一篇的內容。
電路會在哪裡出錯:成本、非原生運算與遺漏的約束
算術化很強大,卻毫不寬容,而有三個誠實的限制塑造了整個領域。第一是成本:每一條約束對證明者來說都是實實在在的時間與記憶體開銷,而對真實程式來說,約束數量會爆炸。在 zkEVM 裡證明 EVM 的正確執行,每個區塊動輒數百萬條約束,這也是為什麼「證明」是整個技術堆疊中沉重、專門、又經常吃 GPU 的那一塊。降低約束數,正是 ZK 工程的核心競技項目。
第二是非原生運算。體上的算術沒有 `<`、沒有整數除法、沒有逐位元 AND。要比較兩個數,你得先把它們位元分解:每個位元引入一條電線、約束每個位元為布林值(0 或 1)、約束這些位元重組回原值,然後逐位元比較——光是一次 256 位元的比較就可能花上數百條約束。除法 `c = a / b` 的做法不是去做除法,而是讓證明者把答案 `c` 當作見證提供,再加上約束 `c × b = a`。電路檢查的是一個猜測值,而非去計算它;證明者在電路外完成苦工,只在電路內證明其一致性。
第三個——也是最危險的——是約束不足的電路。因為見證是證明者提供的,任何你沒釘死的值,都是攻擊者可以自由挑選的值。最經典的例子是布林值:你打算讓電線 `b` 當作 0/1 的選擇器,但如果你忘了約束它,惡意的證明者就能把 `b` 設成 `7`,並滿足那些假設了 `b ∈ {0,1}` 的下游「if」邏輯。
// b is meant to be a 0/1 selector used elsewhere as: out = b*high + (1-b)*low
signal input b;
// BUG (under-constrained): nothing forces b into {0, 1}.
// A malicious prover can set b = 7 and forge 'out' to an
// arbitrary value -- the proof STILL VERIFIES. No error, no crash.
// FIX: one quadratic constraint pins b to a single bit.
b * (b - 1) === 0; // (b)(b-1) = 0 holds ONLY when b == 0 or b == 1這類漏洞之所以陰險,是因為它是無聲的:遺漏的約束從不丟出錯誤、也從不回退。偽造的證明就這麼通過驗證,那句假話被當成真理接受——這是對健全性的直接破壞,而健全性本該讓「說謊」變得不可能。約束不足的電路,一向是 ZK 稽核中最常見的發現。這正是為什麼 ZK 程式碼需要專門的審查、為什麼會有各種 DSL 與工具(編譯成 R1CS 的 Circom、Halo2、Noir、Cairo)以及自動偵測約束不足的工具存在,也是為什麼寫電路自成一門學問,而非一般的程式設計。