编码一次,作弊染遍全局
这是这本书的招牌,而它一点也不神秘。把那 1024 个数看成一条曲线,再把这条曲线在 65536 个点上求一遍值。就这一步。做完之后,作弊者哪怕只改原表里的一格,新旧两张编码表也会有 64513 格对不上——他想小改也小改不了。同样戳三格,抓住的概率从 0.29% 变成 99.9996%。而这一切成立的理由,是你初中就学过的一句话。
回到那个死胡同
第 10 章把程序摊成了一张表。现在验证者面对的困境是:
读整张表 → 和重算一遍没区别(第 1 章说这不叫「简洁」) 只戳几格 → 作弊者改一格,抓住的概率 0.29%(第 3 章)
第 3 章已经指出了唯一的出路:把「作弊改多少」这个旋钮从他手里拿走。现在动手。
第一步:1024 个数 = 一条曲线
初中的事实:两点定一条直线,三点定一条抛物线。推广开来——
给定 n 个点(横坐标互不相同),有且只有一条次数不超过 n−1 的多项式曲线穿过它们全部。
所以:那 1024 个数,就是一条 1023 次曲线在 1024 个位置上的取值。这两种表述完全等价——从表能算出曲线(叫「插值」),从曲线能算出表(叫「求值」),来回不丢任何东西。
这一步在有限域里同样成立(第 10 章说过,域里加减乘除齐全,所以插值这套代数照搬)。
第二步:把曲线画长
一条 1023 次曲线,在 1024 个点上有取值,在别的点上当然也有取值。于是我们做一件看起来毫无必要的事:
原表: 曲线在 1024 个位置上的值 ← 证明者本来就有的 编码表:曲线在 65536 个位置上的值 ← 把同一条曲线多算 64 倍的点 信息没有增加(还是那条曲线), 数据量涨了 64 倍。这个倍数叫 blowup。
看起来纯属浪费。但接下来这一步会把它变成整本书里最值钱的 64 倍。
第三步:那句初中的话
一个不恒为零的 d 次多项式,最多有 d 个根。
推论:两条不同的、次数都不超过 d 的曲线,最多在 d 个位置上取值相同。
(因为它们的差是一条不恒为零的、次数不超过 d 的曲线,而「取值相同」就是「差为零」,也就是差的根。)
现在把这句话对准我们的场景:
诚实的表 → 曲线 A(1023 次)
作弊的表 → 曲线 B(1023 次)
两张表只差一格 → A ≠ B(因为插值是唯一的,表不同则曲线必不同)
于是 A 和 B 在整个域上最多有 1023 个位置取值相同。
而编码表有 65536 个位置。所以:
相同的位置 ≤ 1023 → 1.5610%
不同的位置 ≥ 64513 → 98.4390%
作弊者改了一格,编码之后有 98.44% 的位置对不上。而他对此无能为力——他不能选择「只改一点点」,因为编码不给他这个选项。
实测:把 65536 个位置全部数一遍
这不是估算。本机把两张编码表的 65536 个位置逐个比对数了一遍:
原始记录里不同的格子 1 / 1024 = 0.0977% 编码后相同的格子(穷举全域实测) 1023 / 65536 = 1.5610% 理论上限(1023 次曲线最多几个根) 1023 编码后不同的格子 64513 = 98.4390% 放大倍数(blowup) 64×
实测值 1023,理论上限 1023——顶到了。(顶得这么准是因为两条曲线的差恰好是「过那 1023 个未被改动的插值点」的那条曲线,它在这些点上必然为零,一个不多一个不少。)
于是那张抽查表整个翻了个面:
| 你戳几格 | 原始记录 | 编码之后 |
|---|---|---|
| 1 格 | 0.0977% | 98.439026% |
| 2 格 | 0.1952% | 99.975634% |
| 3 格 | 0.2927% | 99.999620% |
| 5 格 | 0.4873% | 99.99999991% |
戳三格漏掉的概率是 3.804 × 10⁻⁶,也就是 262914 分之一。而第 3 章里同样戳三格,抓住的概率只有千分之三。
改多改少,一个样
还有一个细节值得单独拿出来,因为它是这套机制真正的杀招:
作弊者把那一格 +1 → 编码后相同的格子 1023 作弊者把那一格 +31337 → 编码后相同的格子 1023(一样)
改动的「大小」完全不影响结果。只要改了,就是 98.44%。这跟你的直觉可能相反——在普通的数据里,改一个比特和改一整段,痕迹的大小明显不同。而在这里,「改了」和「没改」之间是一道悬崖,中间没有斜坡。
这正是第 3 章那句话的兑现:扩散率 δ 不再由作弊者决定,而是由编码方式决定,被钉在 0.98 附近。
这个动作在文献里有好几个名字,它们说的是同一件事:
- 低度扩展(low-degree extension,LDE):强调「把一个低次多项式扩展到更多点」。
- 里德–所罗门编码(Reed–Solomon code):1960 年发明的纠错码,和二维码、CD、DVD、卫星通信、RAID-6 用的是同一个东西。它当年要的性质和这里一模一样:任何小扰动都产生大差异。
- 码距(distance):两个不同码字之间至少有多少比例的位置不同。上面那个 98.44% 就是码距。
这一步没有任何密码学。它是 1960 年的纠错码理论,比公钥密码还早。零知识证明的核心引擎,是一件在通信工程里已经用了六十年的旧工具。
工业界为什么不用 64 倍
blowup 越大,码距越大,需要抽查的次数越少。那为什么不用 1000 倍?因为编码表是要被证明者算出来、承诺掉、并存起来的。blowup 直接乘在证明者的成本上。
真实系统的典型选择是 blowup = 8:
| blowup | 码距(至少) | 每格逃脱概率 | 压到 2⁻¹⁰⁰ 要抽几格 | 证明者成本 |
|---|---|---|---|---|
| 64 | 98.4% | 1/64 | 17 | ×64 |
| 8 | 87.5% | 1/8 | 34 | ×8 |
| 2 | 50% | 1/2 | 100 | ×2 |
这是一笔非常清晰的交易:blowup 换查询次数。而查询次数直接决定证明大小(第 13 章会算:34 次查询 ≈ 17.1 KB)。「证明大小」和「证明者成本」在这里是同一个旋钮的两端,第 16 章那张选型表的第一行就是它。
上面全部的推理都建立在一个前提上:作弊者提交的那张 65536 格的表,确实是某条 1023 次曲线的求值。
可他凭什么要老实?他完全可以交上来一张乱填的表——比如在诚实编码表的基础上只改动少数几个位置。这样的表根本不对应任何低次曲线,上面那条「最多 1023 个点重合」的推理对它不适用。
所以真实的协议里还必须有一步:让验证者确信「你交的这张表,确实接近某条低次曲线」。这一步叫低度测试,工业界的做法叫 FRI——它的思路依然是抽查,只是抽的方式更巧(反复把曲线「对折」成一半长度,每折一次抽查几个点是否自洽,折 log n 次直到短得可以直接读完)。
大部分零知识科普会跳过这一段,于是留下一个不成立的论证。这本书不跳过,但也不展开——FRI 的完整可靠性分析是一篇正经论文的量,第 23 章给了入口。你只要记住这个洞的形状:「抽查能抓住小改动」这句话,前提是被抽查的东西真的是一份编码。
这一章那个招牌数字,你可以在自己机器上花 0.3 秒复现出来。纯标准库,六十行:
import hashlib
P, G = 65537, 3 # 素数域,3 是它的原根
def ntt(a, inverse=False): # 快速求值/插值(数论变换)
n = len(a); a = a[:]; j = 0
for i in range(1, n):
bit = n >> 1
while j & bit: j ^= bit; bit >>= 1
j ^= bit
if i < j: a[i], a[j] = a[j], a[i]
ln = 2
while ln <= n:
w = pow(G, (P - 1) // ln, P)
if inverse: w = pow(w, -1, P)
for i in range(0, n, ln):
wn = 1
for k in range(ln // 2):
u, v = a[i+k], a[i+k+ln//2] * wn % P
a[i+k] = (u + v) % P
a[i+k+ln//2] = (u - v) % P
wn = wn * w % P
ln <<= 1
if inverse:
ni = pow(n, -1, P); a = [x * ni % P for x in a]
return a
def lde(vals, N=65536):
coef = ntt(vals, inverse=True) # 插值:1024 个值 → 1023 次曲线
return ntt(coef + [0] * (N - len(vals))) # 求值:曲线 → 65536 个点
# 第 3 章那张计算记录
h = hashlib.sha256(b'spotcheck-mimc').digest(); C = []
for i in range(1024):
if i % 16 == 0: h = hashlib.sha256(h).digest()
C.append(int.from_bytes(h[(i%16)*2:(i%16)*2+2], 'big') % P)
x, trace = 12345, []
for i in range(1024):
trace.append(x); x = pow(x + C[i], 3, P)
fake = trace[:]; fake[700] = (fake[700] + 1) % P # ← 只改一格
print('原始记录不同的格子:', sum(a != b for a, b in zip(trace, fake)), '/ 1024')
A, B = lde(trace), lde(fake)
same = sum(a == b for a, b in zip(A, B))
print('编码后相同的格子: %d / 65536 = %.4f%%' % (same, 100 * same / 65536))
for k in (1, 2, 3):
print('抽 %d 格:原始 %.4f%% 编码后 %.6f%%'
% (k, 100*(1-(1-1/1024)**k), 100*(1-(same/65536)**k)))
原始记录不同的格子: 1 / 1024 编码后相同的格子: 1023 / 65536 = 1.5610% 抽 1 格:原始 0.0977% 编码后 98.439026% 抽 2 格:原始 0.1952% 编码后 99.975634% 抽 3 格:原始 0.2927% 编码后 99.999620%
三个实验值得亲手做:(1) 把 + 1 换成 + 31337,相同格子还是 1023。(2) 改两格、改十格,相同格子只会更少(≤ 1023 恒成立)。(3) 把 N=65536 改成 N=8192(blowup 8),相同格子仍是 1023,但占比变成 12.5%——正好是上面那张表的第二行。
python3 lde.py # 本机 0.3 秒
在线跑:python.org/shell(65536 点的 NTT 在网页版可能偏慢,可先把 N 调到 8192)。想看工业级实现,找 winterfell(Rust)或 plonky3 的 ldt 模块。
- 你口袋里就有一个。二维码用的是里德–所罗门码,最高纠错等级下遮住 30% 仍能读出来。它和这一章用的是同一个编码——只是那里用「大码距」来恢复被毁的数据,这里用它来抓被改的数据。一个正着用,一个反着用。
- CD 上的划痕。1982 年的 CD 标准里就有 RS 码,能纠正连续 4000 个比特的错误(约 2.5 毫米的划痕)。旅行者一号从太阳系边缘发回的数据、每一次卫星通信、RAID-6 阵列,全都靠它。这个 1960 年的工具,六十年后成了零知识证明的引擎。
- 「哈希也能做到类似的事」——但不能。哈希确实让「改一个比特就完全变样」,但它没有中间层次:你只能验整个文件(要读完),不能「抽查几个位置」。RS 编码的价值恰恰在于它把整体的一致性摊到了每一个局部,于是抽查才有意义。
- 数据可用性采样。以太坊的 Danksharding 路线用这一章这套东西来解决另一个问题:如何让一个轻节点确信「这一大块数据真的被发布了」,而不必下载它。做法就是把数据 RS 编码,然后随机抽查几十个位置。同一把锤子,第二颗钉子。
「把数据变长 64 倍,信息量也就多了 64 倍,所以验证者要检查的东西更多了才对——这怎么可能让验证变便宜?」
编码没有增加任何信息。65536 个值和那 1024 个值携带的信息完全相同——它们是同一条曲线,1024 个点就把它定死了,剩下 64512 个点是推出来的,不是新的。
多出来的不是信息,是冗余。而冗余的用处正是:让任何一处的谎言,在很多个地方同时露出马脚。验证者不需要读完 65536 个值,他只要戳 3 个——正因为谎言被摊薄到了几乎每一个位置上。
这里有一个更一般的判据,值得从这本书里带走:「加冗余」和「加信息」是两件事。校验位、副本、纠错码、审计留痕,全都是在不增加信息的前提下增加冗余;而冗余买到的东西是「局部可检查性」——你不用看全部,就能对全部下判断。这句话是整个零知识证明大厦的地基,而它跟密码学没有一点关系。
这一章的一句话
把一张表看成一条曲线、再把曲线画长几十倍,就能把「改一格」强行变成「错几万格」;抽查从此有了意义,而这一步用的是纠错码,不是密码学。
但我们还差一步。上面比较的是两张表——可现实中验证者手里只有一张表,他没有诚实版本可以对照。他真正要检查的是:「这张表满足第 10 章那 1620 条约束吗?」
下一章把这两件事接起来:「一堆约束全部成立」可以被改写成「一条多项式恒等式成立」,而验证一个多项式恒等式,只需要在随机的一个点上代进去算一次。而且这一章会给你一个完全不含密码学的、可以当场感受的例子——验一个 512×512 的矩阵乘法,不重算,快 58 倍。