前面四讲里,读到过三句听起来很有说服力的话。这个实验去查它们是不是真的。

打个比方

这像把说明书上的参数拿到台架上实测一遍

前四个实验都在造零件。这一个不造任何东西——它验的是三个"安全性论证"能不能站得住,而这三句话的共同特点是:它们都可以被算出来、跑出来,不需要相信任何人。

⚠️ 提前预警:二分争议那一部分,绝大多数时间会花在下标上

“第几步"“区间的左右端点"“谁该在这一轮回答”——这些差一位的错误不会崩溃,只会让二分收敛到错误的那一步,而且看起来一切正常。建议先在纸上把一个 8 步的例子完整走一遍,再动手写。

对应第 26–31 讲

前四个实验都在造一条链的零件。这一个不同:它验证的是三个"安全性论证"能不能站得住。

第 27 讲说:争议可以被压缩到一步,所以 L1 裁决得起
第 29 讲说:纠删码让随机采样有效,所以轻节点验得起
第 31 讲说:TWAP 让操纵变贵,所以价格骗不动

⭐ 这三句话都是【可以被算出来、跑出来】的。这个实验就是去跑一遍。

第一部分:交互式二分挑战

任务 1.1:可执行的争议对象

先要有一台能一步一步走、并且每一步状态都能被承诺的机器:

// VM 是一台极简状态机:状态就是一个寄存器和一个程序计数器。
type VM struct {
	PC  uint64
	Reg uint64
}

// Step 执行一步。程序固定:Reg = Reg*3 + 1,取模防溢出。
func (v *VM) Step()

// StateHash 是这一时刻状态的承诺。
func (v *VM) StateHash() [32]byte

// Trace 返回执行 n 步过程中【每一步之后】的状态哈希。
// ⚠️ 长度是 n+1:包含第 0 步(初始状态)。
func Trace(initial uint64, n uint64) [][32]byte

任务 1.2:两个不同意的执行者

// HonestTrace 老实执行。
// CheatTrace 在第 cheatAt 步动手脚(把结果加 1),此后一路错下去。
//
// ⭐ 关键性质:两条 trace 在 cheatAt 之前【逐字节相同】,
//    从 cheatAt 开始【全部不同】。
//    二分要找的就是这个分界点。
func HonestTrace(initial uint64, n uint64) [][32]byte
func CheatTrace(initial uint64, n uint64, cheatAt uint64) [][32]byte

任务 1.3:二分协议

// Bisect 在 [lo, hi] 区间上二分,直到 hi − lo == 1。
// 每一轮:双方各自公布中点的状态哈希;
//   相同 ⟹ 分歧在后半段;不同 ⟹ 分歧在前半段。
// 返回分歧的那一步,以及用掉的轮数。
func Bisect(a, b [][32]byte, n uint64) (step uint64, rounds int)

n = 2²⁰(约一百万步)、cheatAt = 314159 跑一次,断言:

① 找到的分歧步 == 314159
② 轮数 == 20
③ ⭐ 双方在整个过程中一共传输了多少字节?
   (20 轮 × 每轮一个 32 字节哈希 × 2 方 = 1280 字节)
   —— 对照:把一百万步的完整 trace 发上链是 32 MB

⚠️ 轮数是 ⌈log₂ n⌉ 不是 ⌊log₂ n⌋n = 1000000(不是 2 的幂)再跑一次,确认答案是 20 而不是 19——这是一个很容易写错、而且错了也"看起来对"的地方。

任务 1.4:单步裁决

// ArbitrateStep 是【链上】那一步:给定分歧点前一步的状态,
// 自己执行一步,看谁的结果对。
// ⭐ 这是全过程中 L1 唯一需要做的计算。
func ArbitrateStep(preState uint64, claimA, claimB [32]byte) (winner string)

断言:诚实方胜出,且 ArbitrateStep 只调用了一次 VM.Step

到这里,第 27 讲的核心论证就被证完了:一百万步的争议,链上只算了一步。

任务 1.5:把"至少一个诚实挑战者"这条假设打破

这是本部分最重要的任务。 前面证明了机制有效——现在证明它的前提有多脆弱:

// SimulateChallenge 模拟一个挑战期。
// censorProb: 攻击者每个区块成功审查掉挑战交易的概率
// challengeWindow: 挑战期长度(区块数)
// 返回:欺诈是否成功通过
func SimulateChallenge(censorProb float64, challengeWindow int) bool

跑一万次,填这张表:

                       挑战期 = 100 块   = 5000 块   = 50400 块(约 7 天)
每块 50% 审查成功            ?              ?              ?
每块 90% 审查成功            ?              ?              ?
每块 99% 审查成功            ?              ?              ?

然后回答两个问题:

① 挑战期为什么必须以【时间】计而不是以【区块数】计?
   (提示:链本身可能被拥堵或重组)
② ⚠️ 如果全网只有一个挑战者,而他恰好宕机三天,
   七天挑战期还剩多少有效长度?
   ⟹ "至少一个诚实且【在线】的挑战者"里,
      "在线"两个字承担了多少重量?

第二部分:数据可用性

任务 2.1:Reed–Solomon 纠删码

不要调库——用多项式插值从零实现,才能看清"任意一半就能恢复"是怎么来的:

// Encode 把 n 个数据块扩展成 2n 个:
//   把数据块看作多项式在 x = 0..n−1 处的取值,
//   插值出这个 n−1 次多项式,再求它在 x = n..2n−1 处的值。
// ⭐ 于是任意 n 个点都能唯一确定这个多项式 ⟹ 任意一半可恢复。
func Encode(data []Fp, n int) []Fp

// Decode 从任意 n 个 (索引, 值) 对恢复出原始数据。
func Decode(points map[int]Fp, n int) ([]Fp, error)

在一个小素数域上做(比如 p = 2³¹−1),拉格朗日插值即可。

测试:

func TestAnyHalfRecovers(t *testing.T) {
	// n = 16,扩展成 32
	// ⭐ 随机挑 16 个点(一千次不同的组合),每次都必须恢复出原始数据
	// 然后挑 15 个点,断言【无法】唯一确定
}

任务 2.2:采样与检测概率

// Withhold 构造一个"藏起 fraction 比例的块"的场景。
// Sample 随机抽 k 个位置,返回是否全部拿到。
func SampleAll(available []bool, k int, rng *rand.Rand) bool

跑模拟,对照第 29 讲第八节的理论值:

攻击者藏起的比例    k=10 全通过   k=30 全通过   理论值 (1−f)ᵏ
──────────────────────────────────────────────────────────────
   1%(无纠删码)        ?             ?          0.74(k=30)
  51%(有纠删码)        ?             ?          ≈ 1e−9(k=30)
──────────────────────────────────────────────────────────────

这张表的两行就是整个纠删码方案存在的理由:它没有让"藏数据"变得不可能,它让"藏数据"从藏 1% 变成了必须藏一半以上

任务 2.3:编码错误证明

⚠️ 采样只能证明"数据拿得到”,不能证明"编码是对的”。 一个恶意出块者可以发布一份不自洽的扩展数据——每个块都能取到,但它们不在同一个多项式上,于是恢复出来的是垃圾。

// CorruptEncoding 生成一份"每块都可用、但整体不自洽"的扩展数据。
func CorruptEncoding(data []Fp, n int) []Fp

// ProveBadEncoding 生成编码错误证明:
//   拿出 n+1 个点,其中任意 n 个插值出的多项式,
//   都与第 n+1 个点矛盾。
// ⭐ 证明大小是 O(n),这正是为什么以太坊转向了 KZG——
//    KZG 的证明是【常数大小】。
func ProveBadEncoding(shares []Fp, n int) (proof []int, ok bool)

测试:对正确编码 ok == false(造不出证明),对 CorruptEncoding 的输出 ok == true

第三部分:预言机操纵成本

⭐ 台架实测的第三项:第 31 讲说"TWAP 让操纵变贵"——贵多少? 这一部分把它算成一个具体的数字,而你会发现这个数字对池子深度极其敏感。

任务 3.1:把恒定乘积池跑起来

// Pool 是一个恒定乘积做市商。
type Pool struct{ X, Y float64 }

func (p *Pool) Price() float64            // y / x
func (p *Pool) SwapYForX(dy float64) float64  // 投入 Y,得到 X

// PushPriceTo 返回把价格推到 k 倍所需投入的 Y 数量,
// 并断言它等于第 31 讲的解析解 y·(√k − 1)。
func (p *Pool) PushPriceTo(k float64) float64

任务:用数值模拟验证解析解。 这是一次很好的"公式对不对"的检验——推导错了,模拟马上会分道扬镳。

任务 3.2:跨区块 TWAP 攻击

// AttackTWAP 模拟:攻击者在 window 秒的窗口里,
// 从某一刻起把现货价推到 spotMultiple 倍并维持 hold 秒,
// 返回最终的 TWAP 相对初始价格的倍数。
func AttackTWAP(window, hold, spotMultiple float64) float64

用模拟反解出第 31 讲那张表,并加一列

维持时长    现货要推到   需要资金(×储备)   ⭐ 期间被套利者拿走多少
  12 秒       151 倍        11.29            ?
  60 秒        31 倍         4.57            ?
 300 秒         7 倍         1.65            ?

⚠️ 最后一列要自己建模:每个区块假设有一个套利者把价格拉回一部分。你会发现"维持得久"这条路的真实成本远高于表面的资金量——这解释了为什么现实中长窗口 TWAP 的操纵极少见。

任务 3.3:滞后的代价

// 构造一段真实感的价格序列:10 分钟内下跌 40%
// 分别用 现货价 / 5 分钟 TWAP / 30 分钟 TWAP 判断一批仓位是否该被清算
// ⭐ 统计:30 分钟 TWAP 下有多少仓位【本该清算却没有】,
//    以及这些仓位造成了多少坏账

这个任务的产出是一张权衡曲线:横轴 TWAP 窗口长度,纵轴一边是操纵成本、一边是坏账规模。看到这两条曲线反向交叉,比记住"TWAP 有滞后"这句话有用得多。

常见错误

① ⭐ 二分的轮数写成 ⌊log₂ n⌋。n 不是 2 的幂时会少一轮,
   而"少一轮"意味着争议没被收敛到单步 —— 裁决直接失效。
② Trace 的长度写成 n 而不是 n+1,导致分歧点整体偏移一位。
③ 拉格朗日插值在整数上做 ⟹ 溢出。必须在有限域里做模逆。
④ 采样模拟时用 rand.Intn 有放回抽样,却按【无放回】算理论值
   ⟹ 两边对不上。先想清楚采的是哪一种。
⑤ ⚠️ 把"藏 51%"写成"藏 50%"。恰好一半时数据仍可恢复,
   攻击者必须【超过】一半 —— 这个边界正是纠删码的全部价值所在。
⑥ TWAP 累加器在价格更新时用【新】价格结算上一段时间。
   必须用旧价格 —— 这是"时间加权"的定义。

相关第 27 讲第 29 讲第 31 讲