第 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 的哈希漏掉某个公共输入。
这个实验特意让你踩一次,但真实代码里它是静默的。
⑤ 匿名集统计时把"池子大小"当成"匿名集大小" ——
前者是密码学上限,后者是启发式过滤之后的实际值。
⑥ 三明治攻击忘了算攻击者自己的两笔交易的手续费和滑点,
会高估利润。