JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

把一段運算變成可被證明的電路

多項式與證明系統讀不懂你的程式碼——它們只看得懂有限體上的算術。本文帶你看一段程式如何被攤平成一個個閘、再化為秩一約束系統,讓「我確實正確地執行了它」變成一把可驗證的方程式。

為什麼證明需要的是方程式,而不是程式碼

在上一篇你認識了零知識證明的三個承諾——完備性、健全性與零知識——也看到了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³ + x + 5 攤平成四個單一運算的閘。

現在這段運算成了一串平坦的四個閘,作用在五條工作電線上(`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
x³ + x + 5 = 35 的四條 R1CS 約束,每一條都用 x = 3 的見證逐一驗證。

現在檢查整個證明主張變得機械化:一個見證為有效,當且僅當每一條約束都成立。沒有執行、沒有直譯器——只剩幾個內積與乘法。

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
R1CS 是否被滿足:整個「程式是否正確執行?」的問題,化約成這個迴圈。

見證:公開、私密,以及其間的一切

見證 `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.
Circom 裡只有一條約束的因式分解電路:a × b = n。

從 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)以及自動偵測約束不足的工具存在,也是為什麼寫電路自成一門學問,而非一般的程式設計。