第 32 讲说 Σ-协议"满足特殊可靠性和零知识"。读的时候大概点了头。

这个实验要你把这两句话写成两个能跑的程序——写完你会发现,之前那个点头是虚的。

打个比方

这像**“我懂这道菜怎么做"和真的把它做出来**的差别。

“存在一个抽取器"和"存在一个模拟器"听起来像形容词,是对协议性质的描述。它们不是。 它们字面上就是两段代码:一段真的能从两次对话里挖出私钥,一段在不知道私钥的情况下真的能造出一模一样分布的对话。

⚠️ 实现它们是理解零知识唯一可靠的方式——因为在你写出模拟器之前,“零知识"这个词对你来说只是一个承诺。

对应第 32–35 讲

这个实验的特点是:它要求你把两个"定义"写成代码。

第 32 讲说 Σ-协议满足两条性质:
   ⭐ 特殊可靠性 —— 存在一个【抽取器】,能从两次对话里挖出私钥
   ⭐ 零知识     —— 存在一个【模拟器】,不知道私钥也能造出同分布的对话

⚠️ 这两句话不是形容词,是【两个可以被实现出来的程序】。
   实现它们,是理解零知识唯一可靠的方式。

第一部分:Schnorr Σ-协议

任务 1.1:交互式协议

在 secp256k1 上(复用实验 1和第 7 讲的曲线代码):

// 证明"我知道 x 使得 P = xG",且不泄露 x。
//
// ① 承诺:证明者随机取 r,发送 R = rG
// ② 挑战:验证者随机取 e
// ③ 响应:证明者发送 s = r + e·x  (mod n)
// ④ 验证:sG == R + eP

func Commit(rng io.Reader) (r *big.Int, R Point)
func Respond(r, e, x *big.Int) *big.Int
func Verify(P, R Point, e, s *big.Int) bool

测试完备性:诚实证明者一千次全部通过。

任务 1.2:抽取器——把"可靠性"写成代码

// Extract 是【特殊可靠性】的构造性证明。
// 给定同一个承诺 R 下、两个不同挑战的接受对话
// (R, e₁, s₁) 和 (R, e₂, s₂),e₁ ≠ e₂:
//
//        s₁ − s₂ = (e₁ − e₂)·x
//   ⟹   x = (s₁ − s₂) · (e₁ − e₂)⁻¹  mod n
//
// ⭐ 这段代码就是"如果他能回答两个不同的挑战,他一定知道 x"这句话本身。
func Extract(e1, s1, e2, s2 *big.Int) *big.Int

测试:Extract 恢复出的值必须等于真正的私钥。

然后想清楚这件事的含义:一个不知道 x 的骗子,最多只能答对一个挑战(他可以预先挑好 e 再倒推 R)。他答不出第二个——不是因为他不够聪明,是因为一旦答出来,上面这三行代码就能把 x 算出来,而他没有 x

任务 1.3:模拟器——把"零知识"写成代码

// Simulate 是【零知识】的构造性证明。
// 它不知道 x,却能产出一个通过验证的对话:
//   ⭐ 反着来 —— 先随机选 e 和 s,再令 R = sG − eP
//
// 于是 (R, e, s) 必然满足 sG == R + eP。
func Simulate(P Point, rng io.Reader) (R Point, e, s *big.Int)

这是本部分最重要的测试:

func TestTranscriptsIndistinguishable(t *testing.T) {
	// 生成一万个【真实】对话和一万个【模拟】对话
	// ⭐ 断言:两组的 (R, e, s) 分布在统计上无法区分
	//    (e 和 s 都是均匀随机,R 由前两者唯一决定)
}

看懂这个测试,就看懂了零知识

真实对话里【有】私钥参与,模拟对话里【没有】,
而两者的分布【完全一样】。

⟹ 一个只看对话的观察者,得不到任何关于私钥的信息 ——
   因为他看到的东西,不知道私钥的人也造得出来。

⚠️ 注意模拟器为什么不能被用来作弊:它必须先知道 e 才能算 R。真实协议里 R 必须先发,e 后到——顺序就是全部的安全性所在。

任务 1.4:Fiat-Shamir 与一个必须亲手踩的坑

// 用哈希代替验证者的随机挑战,协议变成非交互的:
//        e = H(R ‖ P ‖ message)
func FiatShamir(P, R Point, msg []byte) *big.Int

先实现正确版本并测试通过。然后实现错误版本:

// ⚠️ 弱 Fiat-Shamir:挑战里【漏掉了公钥 P】
//        e = H(R ‖ message)
func WeakFiatShamir(R Point, msg []byte) *big.Int

任务:针对弱版本构造一个伪造。

思路:既然 e 不依赖 P,攻击者就可以【先】定 R 和 s,
      算出 e,再反推出一个能让验证通过的 P' = (sG − R)·e⁻¹。

⟹ ⭐ 他伪造出了一个"针对某个他并不掌握私钥的公钥"的有效证明。

这个坑在真实系统里反复出现过。 教训很具体:Fiat-Shamir 的哈希必须囊括全部公共输入——漏掉任何一项,被漏掉的那一项就变成了攻击者的自由变量。

第二部分:匿名集

⭐ 第一部分做完,你手上有了那两段代码。这一部分换个方向:不是"证明能不能成立”,而是"就算证明完美,你到底躲在多少人里”——而这个数往往比想象的小得多。

任务 2.1:一个最小的混币池

// 存款:把 commitment = H(nullifier ‖ secret) 插入 Merkle 树
// 取款:出示
//   ① 一个 ZK 证明(本实验用"直接给出路径"代替,重点不在证明系统)
//   ② nullifier ——⭐ 用于防双花,且它与存款【无法关联】
type Pool struct {
	tree       *MerkleTree
	nullifiers map[[32]byte]bool
}

func (p *Pool) Deposit(commitment [32]byte)
func (p *Pool) Withdraw(nullifier [32]byte, proof MerkleProof, root [32]byte) error

测试:同一个 nullifier 第二次取款必须失败;伪造的 Merkle 路径必须失败。

任务 2.2:度量匿名集

// AnonymitySet 返回一笔取款【在观察者看来】可能对应的存款集合。
func AnonymitySet(deposits []Deposit, w Withdrawal, heuristics []Heuristic) []Deposit

依次加入启发式,观察匿名集怎么缩小:

启发式                                    剩余匿名集(100 笔存款)
────────────────────────────────────────────────────────────────
无(仅靠 Merkle 树的密码学保证)                  100
+ 金额必须匹配                                     ?
+ 取款时间必须晚于存款时间                         ?
+ 存取款用了同一个地址付 Gas                       ?
+ 存款地址与取款地址此前有过转账                   ?
────────────────────────────────────────────────────────────────

⭐⭐ 这个实验的核心产出是一句话

密码学给出的匿名集是【池子里的全部存款】。
⚠️ 而实际匿名集是【经过全部链上启发式过滤后剩下的】。

⟹ 隐私不是"用了零知识"就有了。
   ⭐ 零知识保证的是【那一条链接】断了,
      而观察者从来不止一条链接可用。

任务 2.3:把匿名集打到 1

func TestUniqueAmountDestroysPrivacy(t *testing.T) {
	// 池子里 99 笔存款是整数金额,1 笔是 3.14159 ETH
	// ⭐ 断言:那一笔的取款,匿名集大小 == 1
	// —— 密码学完好无损,隐私归零
}

⚠️ 这就是为什么真实的混币池强制使用固定面额。 面额自由是一个看起来友好、实际上摧毁隐私的设计。

第三部分:MEV

任务 3.1:三明治攻击的最优规模

// Sandwich 计算一次三明治攻击的利润:
//   ① 攻击者先买,推高价格
//   ② 受害者的交易以更差的价格成交
//   ③ 攻击者卖出
// 返回:攻击者利润、受害者的额外损失
func Sandwich(pool Pool, victimSize, attackSize, slippageTolerance float64) (profit, victimLoss float64)

// OptimalSandwich 求利润最大的 attackSize。
// ⭐ 它不是"越大越好"——推得太狠会触发受害者的滑点保护,
//    交易回滚,攻击者白付 Gas。
func OptimalSandwich(pool Pool, victimSize, slippageTolerance float64) float64

扫描滑点容忍度 0.1% / 0.5% / 1% / 5%,对照第 34 讲的表格,并回答:

① 攻击者利润与滑点容忍度大致成什么关系?
② ⭐ 受害者把滑点容忍度设为 0.1%,他真的更安全了吗?
   (提示:交易失败率会怎样?失败的 Gas 谁付?)
③ 攻击者的利润里,有多大比例最终以 Gas 竞价的形式流向了出块者?

任务 3.2:从 Gas 竞价到密封拍卖

// 模拟两种排序权分配方式,统计【总的链上浪费】:
//   ① 公开 Gas 竞价:失败的抢跑交易也上链,也烧 Gas
//   ② 密封投标(PBS 式):只有赢家的交易上链
func SimulateGasAuction(bidders int, rounds int) (onchainWaste float64)
func SimulateSealedAuction(bidders int, rounds int) (onchainWaste float64)

结论应该是:拍卖没有消灭 MEV,它把 MEV 的外部性(链上垃圾、Gas 价格飙升)内部化了。受害者损失的那部分钱一分没少——第 34 讲的核心论点,在这里变成两个数字的对比。

第四部分:在自己的 EVM 上复现一次重入

复用实验 4的解释器,加上 CALL 和一个最小的余额账本:

// 部署两个合约:
//   Vault:    withdraw() { send(msg.sender, bal[msg.sender]); bal[msg.sender] = 0 }
//                          ⚠️ 先转账,后清零
//   Attacker: 在收到转账的回调里再次调用 withdraw()
//
// ⭐ 断言:Attacker 取走的金额 > 它存入的金额
// 然后把 Vault 改成"先清零,后转账",断言攻击失败。

在自己写的虚拟机上看着余额被掏空,比读十遍"检查-生效-交互"有说服力。 顺带回答:为什么这个 bug 在单线程的 EVM 里也会发生?(提示:第 35 讲说过,重入不是并发问题,是控制权转移问题。)

常见错误

① 抽取器里忘了对 (e₁ − e₂) 求【模逆】而不是普通除法。
② ⭐ 模拟器写成"先算 R 再选 e" —— 那就成了真协议,模拟不出来。
   必须先选 e 和 s,再倒推 R。顺序就是零知识的全部。
③ 判断两组对话"分布相同"时只比较均值。
   要比较的是分布 —— 至少做一次卡方检验或直方图对比。
④ ⚠️ Fiat-Shamir 的哈希漏掉某个公共输入。
   这个实验特意让你踩一次,但真实代码里它是静默的。
⑤ 匿名集统计时把"池子大小"当成"匿名集大小" ——
   前者是密码学上限,后者是启发式过滤之后的实际值。
⑥ 三明治攻击忘了算攻击者自己的两笔交易的手续费和滑点,
   会高估利润。

相关第 32 讲第 33 讲第 34 讲第 35 讲