前面四讲里,你读到过三句听起来很有说服力的话。这个实验去查它们是不是真的。
打个比方
这像把说明书上的参数拿到台架上实测一遍。
前四个实验都在造零件。这一个不造任何东西——它验的是三个"安全性论证"能不能站得住,而这三句话的共同特点是:它们都可以被算出来、跑出来,不需要相信任何人。
⚠️ 提前预警:二分争议那一部分,绝大多数时间会花在下标上。
“第几步"“区间的左右端点"“谁该在这一轮回答”——这些差一位的错误不会崩溃,只会让二分收敛到错误的那一步,而且看起来一切正常。建议先在纸上把一个 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 累加器在价格更新时用【新】价格结算上一段时间。
必须用旧价格 —— 这是"时间加权"的定义。