限制每区块的结算工作(#432)
状态:修复 A 已实现(默认值确认为 1.000 VIZ),修复 D 已完成——垃圾回收、结算和作废退款 都在一个计量的行预算上运行。本文记录问题、权衡过的选项,以及为什么链条两个都要。
结合 §4.1–§4.12 的后续修补,这些边界封堵了同一个攻击向量:通过垃圾操作使预测市场 cron 在每个区块 中的工作量无限增长。 现在 cron 的每次清扫要么运行在按行计量的预算上(pm_settle_rows_per_block), 要么受经济门槛约束(一行至少 pm_min_liquidity = 100 VIZ),要么受每个市场的硬上限约束——而唯一仍 仅受经济约束的遍历会发出响亮的日志信号,而不是静默退化。第 7 节汇总了已封堵的攻击面。
姊妹内部规范:early-exit-deferred-claim、 specification §5(crons)。
1. 漏洞
pm_processing_cap_per_block(中位数投票,默认 200)是 PM cron 的唯一限制器。它计的是市场, 不是工作:
while (it != idx.end() && ... && done < cap) { // §5 auto-payouts
settle_market(*this, mkt); // 触及市场的每一条下注行
++done; // ...而只花恰好一个 cap 单位
}settle_market()(libraries/chain/pm_process_markets.cpp)遍历整个 by_market 下注范围, 构建赢家/输家向量,通过 adjust_balance 支付每一行,翻转它的状态,并为每一行推入一个虚拟操作。 没有游标也没有 resume:一个市场要么整个在一个区块内结算完,要么根本不结算。同样的形态出现在 gc_market()(在一个区块内丢弃一个市场的整个对象簇)和 void/no-contest 分支中。
没有任何东西限制一个市场可以携带的行数:
- 每个
pm_place_bet创建一个新的pm_bet_object—— 没有按(account, market, outcome)聚合; - 即时路径完全没有最低下注(
amount > 0和tokens_out > 0是仅有的门槛),所以一行只花 1 raw = 0.001 VIZ。已在测试网上通过实时广播验证,带拒绝对照和接受对照,在 0.001 / 0.002 / 0.010 VIZ 下; - 一次部分的
pm_transfer_position把一行拆成两行,完全不需要任何押注成本——比下注还便宜, 而且绕过了任何下注侧的下限; - 同一类上限在其他地方早已存在——
MAX_PM_DEFERRED_CLAIMS_PER_MARKET、pm_dispute_votes_per_market、MAX_PM_OPEN_COMMITS_PER_MARKET,全是 10 000。下注行是这一类 里唯一被留下的敞口。(pm_dispute_votes_per_market后来提高到 100 000 并加了质押门槛,见 §4.6。)
这不需要攻击者。一个仅仅热门的市场就会撞进去:在测试网上,一个玩具机器人每十分钟从三个账户 下注,已经在一个市场上累积了 734 行(另外两个是 571 和 552)。一个有数千参与者的 mainnet 市场要大 好几个数量级,而所有这些工作都落在争议宽限期到期的那个单一区块里。
2. 选项
| 修复 | 限制工作? | 成本 | |
|---|---|---|---|
| A | 即时路径上的最低下注(pm_min_batch_bet 的镜像) | 否——只是给行定价 | 一个断言,中位数可调 |
| B | 每个市场的行数硬上限 | 是 | 热门市场停止接受下注:审查 / 破坏 UX |
| C | 按(account, market, outcome)聚合下注 | 受账户数限制 | 侵入式:破坏 per-bet weight、time_penalty、entry_liquidity、transfer_position、F1 权益 |
| D | 增量结算:每区块有界行数,市场上游标 | 是 | 最大的共识差异 |
选定:A + D。
- 仅 A 不是修复。 它把一行的价格抬高三个数量级(0.001 → 1.000 VIZ)且可投票,但它给出的边界是 经济性的,不是结构性的:1 000 000 行 × 1 VIZ = 1 000 000 VIZ,这是很多钱,但不是不可能的金额 ——而且它是质押的,不是花掉的,所以很大一部分会在赔付时回来。更重要的是,A 对合法情形完全 无能为力:一个真正热门的市场不是垃圾,也不该被惩罚,可它恰恰是同一个区块时间问题。
- 仅 D 也不够。 它让每区块的工作有限,但行仍是免费的,所以垃圾发送者仍能把一个市场的结算拉伸 到数千个区块,迫使每个节点扛着状态。A 是廉价的经济护栏,让 D 的队列保持短。
- B 被否决:在一个表现良好的市场上拒绝下注是产品对用户可见的失败,而且上限值还得靠猜。
- C 被否决:为同样的收益,它是比 D 更宽、更冒险的差异,而且它摧毁了结算数学和提前退出权益 所依赖的 per-bet 属性。
3. 修复 A —— 最低下注(已实现)
chain_properties_pm 增加了两个中位数投票参数:
pm_min_bet—— 默认1.000 VIZ,治理下限0.1 VIZ(validate()),是pm_min_batch_bet的 镜像;pm_settle_rows_per_block—— 默认2000,范围[100, 100000],被修复 D 消费。
两者都接入了 database.cpp 的中位数循环。一个 PM 参数,如果声明了、反射了、验证了却从不进入这个 循环,就会默默不可投票、冻结在代码默认值上——pm_closed_market_retention_sec 已经发生过一次。
强制点(libraries/chain/pm_evaluator.cpp):
pm_place_bet,mode == 0(即时)→amount >= pm_min_bet;pm_place_bet,mode == 1(排队批量)→amount >= pm_min_batch_bet。这条路径也创建行,而且 也没有下限——此前只有 commit 路径被覆盖;pm_transfer_position,部分转移 → 转移出去的部分和剩余部分都必须保持在pm_min_bet或 之上。低于下限的仓位不会被困住:它仍可被整体转移,那是移动行而不是拆分它。pm_add_liquidity→pm_min_liquidity。流动性是第四个行来源,也是最初漏掉的那个:每个调用都铸 出自己的pm_liquidity_object(贡献不按提供者合并),而求值器只断言amount > 0,所以在下注路径 被加上下限的同时,行仍可以每 1 raw 一条地铸出。这个下限与创建市场时已经生效的下限相同,所以 投入流动性的最低门票不取决于你是开新市场还是事后加码——这是一个刻意做出的产品决策,而非默认。
4. 修复 D —— 增量结算(设计)
市场携带自己的结算游标,而 cron 花一个全局每区块行预算(pm_settle_rows_per_block,所有正在 结算的市场共享,最老的市场优先)。settle_market 变成一个逐区块恢复的阶段机:
| 阶段 | 每行工作 | 受预算约束 |
|---|---|---|
| 1 force-close | 关闭结算时仍开着的杠杆仓位 | 否——见下 |
| 2 aggregate | 退还排队的行(status 5/6),加总 losers_sum 和赢家权重 | 是 |
| 3 claims | 从有界桶里支付结果相关的提前退出权益 | 是 |
| 4 payout | 支付赢家 / 翻转输家,每行一个虚拟操作 | 是 |
| 5 finalize | 费用、LP 结算、尘埃、payout_status = 3、finalized_time | 否——见下 |
只有下注遍历被计量,因为只有下注行是廉价的。阶段 1 和 5 遍历杠杆仓位和流动性行,而每一条这些 行创建时花 pm_min_liquidity(100 VIZ)——是下注行的一百倍。它们的工作受经济约束,受攻击者为创建 这些行所必须质押的约束,所以给它们计量需要为一条修复 A 已经定价掉的威胁添加游标和 resume 状态。 如果这一点将来改变(出现更便宜的铸 LP 或杠杆行的方式),这些阶段就需要同样的处理和同样的 escrow 纪律。
resume 状态住在哪里
把当前的 settle_market() 从头到尾走一遍,就能得到一次暂停的结算必须携带的确切状态,而它不止是 一个游标:资金拆分是一个两遍算法。第一遍产生聚合(losers_sum、总赢家权重),第二遍把它们变成 逐行赔付。在任何区块边界切断函数,两遍都需要保留各自的部分结果,外加最终化所需的用于尘埃的 累计总量。
这个状态不放在 pm_market_object 上。那是 12 个字段,每个存在过的市场都要携带,而只有正在 结算的那少数几个用得上——这个对象已经约 40 个字段宽,而市场是链上数量最多的对象。相反,当一个 市场进入结算时创建一份 pm_settlement_object,按市场唯一键控,在最终化时移除,所以成本与 在途的结算成正比:
| 字段 | 阶段 | 含义 |
|---|---|---|
phase, cursor | 全部 | 当前阶段及其中下一个要处理的行 id |
stake_total | 2 | losers_sum(正常)/ 总活跃押注(void) |
weight_total | 2 | Σ 赢家曲线权重,如 compute_settlement 中为 128 位 |
winners_pool, uncovered | 设在 3→4 | 权益最终确定后的拆分常量 |
distributed | 4 | Σ 已付利润——最终化把 winners_pool − distributed 作为尘埃路由 |
lp_bonus | 4 | Σ 从赢家收取的时间惩罚 |
paid_claims | 3 | 从有界提前退出桶中取出 |
escrow | 全部 | 带符号的守恒累加器(见下) |
每赢家的赔付只取决于 winners_pool、weight_total 和行自身的字段,所以阶段 4 不需要记住它已经 支付过的行——这正是让切断变干净的原因。void 路径复用同样的字段(它的两次按比例分配累计在 distributed 和 lp_bonus 里),让「最后参与者吸收余数」的四舍五入与今天保持一致。
因为对象在最终化时被移除,一个不在结算的市场根本没有结算行,垃圾回收会把它和簇的其余部分一起 丢掉。
让它安全的规则:
- 确定性。 阶段、游标和累加器住在结算对象里;预算是中位数投票参数。因此每个节点在相同的 区块处理完全相同的行。
- 市场在结算期间对一切封闭。
payout_status = 4(「settling」)阻止 §5 重入,把pm_dispute_create挡在外面(它要求payout_status == 1),而且 GC 无法触发,因为finalized_time只在阶段 5 盖章。 - 每个区块边界的守恒。 从一行释放出来但尚未支付的钱放在一个显式的
escrow累加器里:行释放时+= amount,支付某人时-= payout。PM 供给不变量把它计为 PM 持有,所以在结算中途截取的快照 精确平衡;最终化断言它归零。累加器是带符号的:阶段 3 从一个其行仍站着的输家池里支付提前 退出权益,所以在阶段 4 释放这些行之前,它合法地变为负值。这不是赤字——代币在真实账户余额里,而 将资助它们的行仍被计为 PM 持有,所以不变量的两侧无论如何一起移动。 - 进展。 一个有 N 行的市场在约 N / budget 个区块内完成;预算上 100 的下限使饿死不可能。
- 行自身不能移动。 每个创建、拆分或删除下注行的操作——
pm_place_bet、pm_commit_bet、pm_reveal_bet、pm_cancel_bet、pm_transfer_position——断言mkt.status == 1,而一个正在结算 的市场处于 status 3。所以阶段 2 遍历的集合恰是阶段 4 支付的集合,无需添加门槛。
有一个横切交互不会自动成立,实现必须收掉它:cron §1(未揭示承诺的罚没)把罚金加到该承诺所属 不管哪个市场的 forfeit_pool 里,不看它的状态。今天这无害——§1 在同一区块里比 §5 更早运行,所以 结算读到的是最终的 forfeit_pool——但一个跨越区块的结算可能在阶段 3 已经把 forfeit_pool 折进 winners_pool 之后,又长出一个 forfeit_pool,那些代币于是不属于任何人(孤儿直到 GC 烧掉,即 又是 drift-400 失败模式)。时序让它在实践上不太可能——reveal 截止日在投注关闭时,远早于 result_expiration + grace——但「实践上不太可能」恰恰是产生早先 drift 的推理。因此阶段 5 必须路由 任何中途出现的 forfeit_pool,而不是假设它为零,最终化时的 escrow 断言必须考虑到它。
4.1 已发布:有界垃圾回收
回收先做——它是同样无界的遍历,却没有结算算术,所以它自己就能验证预算管道。gc_market() 变成 gc_market_step(db, mkt, budget):它最多丢弃 budget 个对象,就地递减它,并且只有当整个簇(含 市场对象)都消失时才返回 true。一个对一个区块来说太大的市场保留它在扫描头部的位置—— finalized_time 永不改变——并在下一区块继续。
与结算不同,这里不需要游标:每个范围都在它的 lower_bound 处重新进入,而已被移除的行消失了, 所以扫描恰在它停下的地方恢复。两个细节让暂停安全:
forfeit_pool的烧毁现在在同一步骤里归零,否则重入会在每个区块重新烧掉同样的代币,把current_supply推到已计总和之下;- 一个收到一半的市场是惰性的——终态(
status 3/payout_status 3),所以没有操作能触达它,而被 丢弃的行不持有资金(下注2/3、LP3、杠杆终态),所以供给不变量在整个暂停期间持平。
由 gc_row_budget_spans_blocks(consensus_sim)覆盖:一个 143 行的簇、预算在其 100 的下限上,必须 花不止一个区块,且任何区块都不能丢超过 100 行。对着一个刻意无界的对照验证过——绕过预算时,同样的 测试报告「单个区块移除了 143 行」和「1 个区块内收集完」,即修复前行为。
结算在区块内先于回收被服务,所以沉重的结算积压可以推迟 GC。这无害:它只是拉伸保留期,而结算是 有限的。
4.2 已发布:增量结算
settle_market() 变成 settle_market_step(db, mkt, budget),由上述 pm_settlement_object 驱动, 并花与回收相同的全局行预算。实现必须做对的三件事,没有一件能从设计草图看出来:
- 「我是最后一行吗?」不能靠往前看来回答。 今天的代码把四舍五入余数交给最后一个参与者,它是 通过窥探市场剩余部分来识别的。在 resume 下这种窥探既错误(已支付的行仍在范围内,只是终态)又 平方级。因此阶段 2 把它聚合的行数计入
rows_total,阶段 4 把它已支付的行数计入rows_done; 最后一行就是rows_done == rows_total,O(1) 且在整个暂停期间稳定。 - void 分支必须释放它烧掉的东西。 当作废市场没有剩余参与者来吸收剩余池时,剩余被烧掉——而烧掉 却不从
escrow里减去它,会触发最终化断言。守恒记账必须覆盖销毁路径,而不仅仅是支付路径。 - 每区块工作不能重复一次性副作用。 工作负载计量
markets_in_dispute_window在调用点递减,现在 它在飞行的每个区块都运行;它移进了只运行一次的分支,即市场首次进入结算时(payout_status != 4)。 与回收中forfeit_pool烧毁同一类 bug。
由 settle_row_budget_spans_blocks(consensus_sim)覆盖:一个 140 行的市场、预算在其 100 的下限上 结算,所以必须花不止一个区块,任何区块都不能终结超过 100 行,必须在飞行中显示 payout_status = 4, 并最终以每行已支付、结算对象消失、下注者余额恰好移动行所记录的总和结束。对着一个刻意无界的对照 验证过——绕过预算时,同样的测试报告「单个区块支付了 140 行」和「1 个区块内结算完」,即修复前行为。
4.3 已发布:增量作废退款
两条 void 路径——cron §2(错过裁定)和 §3(争议自动关闭)——有同样的洞,还多一条边: refund_all_bets() 在一个区块内遍历市场的每一行并构建一个持有每个参与者的内存向量,因为罚没池 是按比例共享的,而分母只有在遍历结束时才知道。
refund_market_step(db, mkt, budget) 取代两者。它在同一谓词(status 0/5/6)上跑两个计量的遍: 第一遍只计量(押注总和、行数),第二遍退还押注并支付每行它在罚没池中的份额。因为第一遍不改动 任何东西,第二遍重新遍历的恰是第一遍数过的集合——这就是「这次作废在退款谁」如何在暂停期间仍可 回答,而无需给行打标或持有向量。
市场从飞行的第一区块起就带着 payout_status = 4,而这个标志现在是一个门槛,而不只是显示值:
pm_resolve_market、pm_no_contest和pm_transfer_position拒绝它,所以一个迟到的预言机调用 无法超越一个正走了一半市场的退款;- cron §4(争议投票最终化)跨过其市场正被 §3 作废的争议;
- cron §6(批量时代结算)跳过它,所以排队的行无法在两个遍之间移动。
写这个的时候掉出一个排序 bug,而且它早于本次改动:旧路径在 return_liquidity() 之前排干 forfeit_pool,而后者强平杠杆仓位并把它们的曲线残差直接路由回 forfeit_pool。那些代币于是 骑在市场行上直到 GC 丢弃它——stranded 在 current_supply 里没有主人,正是 void 路由要防止的那种 泄漏。现在流动性先返回,剩余在其后入账。
由 void_refund_row_budget_spans_blocks 覆盖:140 行、预算在其 100 的下限上、没有预言机来裁定; 作废必须跨越区块、每区块保持在预算之下、以 status = 3、resolved_outcome = -1、每行都被退款、 下注者余额恰好上升押注额结束。
4.4 批量执行器(cron §6)
Cron §6 在一个区块内填满排队到某市场当前时代的每一行,价格是计市场的 cap 的一个单位——与结算 bug 同一形态,而在修复 A 之后,一个排队的行花 pm_min_bet(1 VIZ),恰是一个下注行的成本。在一个 LMSR 市场上,每一行还要额外支付一次曲线报价,使其每行比结算更贵。
它现在从同一个共享 pm_settle_rows_per_block 预算里支取,按访问的行收费(不只是执行的—— 访问就是该区块做的工作)。两个后果来自剩余行按时代匹配这一事实:
- 时代计数器只在队列排干后才推进;中途推进会让剩余行在押注已被扣款的情况下不可达;
- 因此执行器在一个遍在途时(
pm_batch_settle_bet_cursor != 0)也在时代边界之外运行,而不是让 已经揭示的押注为下一个边界等上一整个时代窗口。
resume 点是动态全局属性里的第二个游标 pm_batch_settle_bet_cursor,由 round-robin 扫描访问到的 第一个市场消费(它按构造就是 pm_batch_settle_cursor)。丢掉它——比如一个没有该字段的旧快照—— 是安全的:该遍从时代头部重来,并按状态跳过它已经执行过的行,代价是一次空闲遍历和零资金。由 batch_queue_row_budget_spans_blocks 覆盖。
有一个护栏是承重的而非装饰性的:§6 只在 row_budget > 0 时进入。它最后运行,而预算是共享的,所以 一个结算沉重的区块可能空手到达它这里;无论如何进入都会跑零次迭代然后落入 persist 步骤,而后者—— 看不到中途市场停止——会把一个零行游标写到被停靠的游标之上。下一遍会从时代头部重启,花预算重访它 已经执行过的行。
4.5 截止扫描重读已结算市场(2026-08-19 发现,已修复)
by_result_expiration 的键是 (status, result_expiration, id)。一个已结算市场保持 status == 3, 而它的 result_expiration 停留在过去,所以它坐在 §5 结算扫描所遍历范围的头部——而跳过它不花 cap,所以循环从不在它上面提前停止。因此每个区块都在触及真正的工作之前重读整个已结算积压:在 区块 82641602 的测试网快照上实测为48 971 次迭代,其中 48 942 次是纯 continue,而只有 29 个 市场真正欠一次结算。积压受 GC 保留期(默认 5 天)限制,所以它不是泄漏——但它与周转量成正比,而 攻击者可以直接通过创建和裁定市场来吹大它。
修复把索引键改为 (status, finalized_time, result_expiration, id)。finalized_time 恰好在最终化时 盖一次章,所以 finalized_time == 0 表示「仍欠工作」;两个扫描(§2 错过裁定、§5 结算)都把 lower_bound 落入该组,而市场在结算的那一刻离开它。payout_status 刻意不在键里——结算扫描在飞行 中把它 1 → 4 翻转,不得移动它正在恢复的那一行。与 by_oracle_finalized 同一招。索引键不序列化, 所以无需快照迁移。
4.6 争议计票(cron §4,2026-08-19 发现,已修复)
又是同一形态,就在先前各遍从未看过的那个扫描里。Cron §4 最终化投票窗口已关闭的争议;对每个争议 它遍历被争议市场的每张选票来构建按押注加权的计票,并给市场计一个计市场的 cap 单位。一张 选票也不是便宜的行——每张都要一次账户查找加一次 lazy-pool 存款查找,与 §5 里测得的结算行同一 量级。
M3 已经把每个市场的选票限制在 pm_dispute_votes_per_market——最初加入时为 10 000,2026-08 提高到 100 000 并加了质押门槛(见下文)——那里的注释推理说这让最终化遍历安全。并非如此:这个上限 约束一个市场,而 §4 可以在一个区块内最终化 cap 个这样的市场,所以天花板是 cap × 10 000 = 2 000 000 行——比现在任何其他扫描尊重的预算高三个数量级。填满 它很慢(一张选票每个市场需要一个不同的账户,而 200 个争议要 200 × pm_dispute_fee 的 escrow),但 选票是持久状态:成本摊在数小时的链时间里,而工作在投票窗口到期的那个单一区块里被重放。
与结算不同,计票不能被恢复:裁决需要一次拿到所有选票,而停靠部分逐结果的和意味着在争议行上 携带一个向量。所以预算在争议之间强制——一个争议只在仍有预算时开始,然后按它遍历过的选票收费。 每区块最坏情况变成 row_budget + 一个市场的选票上限,而不是 cap × 选票上限。把一次最终化推迟 一个区块在经济上是惰性的:pm_dispute_vote 拒绝 voting_end_time 之后的选票,所以当 §4 到达时 选民已经最终。由 dispute_tally_row_budget_defers_next 覆盖。
行上限约束了市场能持有的选票数量,但一张选票仍然是免费投出的,所以 Sybil 攻击者可以用灰尘账户 填满上限并锁死合法投票者。2026-08 上限提高到 100 000,并在 pm_dispute_vote 中加入质押门槛 中位数投票的 pm_dispute_vote_min_vesting = 1000.000 VIZ:投选票现在要求投票时 effective_vesting_shares 至少 1000 VIZ,和 pm_min_bet 给下注行定价一样给选票定价。该门槛无法通过委托绕过——撤销委托会把 委托者的 delegated_vesting_shares 扣住 5 天(经 vesting_delegation_expiration_object 的 CHAIN_ENERGY_REGENERATION_SECONDS),所以同一份 1000 VIZ 每 5 天至多投一个账户。填满 100 000 上限 需要约 100 000 × 1000 VIZ = 100 000 000 VIZ ≈ 91% 的供应量(约 110M VIZ),因此上面 row_budget + 选票上限的最坏情况是理论天花板,而非可达到的。计票权重在最终化时从投票者当前 有效质押读取,所以质押在投票后移走的选票计为约 0——这是刻意的:它让总投票权重受实际供应量约束, 并防止通过委托回收造成权重倍增。
它与 §5、§6 共享的排序属性值得陈述一次:预算按小节顺序花掉,所以一个被 void 路径饱和的区块可以 不给它后面的扫描留任何东西。这是刻意的——积压是有限的会排干的工作——但这意味着「我的争议多久 最终化」受在途总 PM 工作的约束,而不只是 §4。
相关的每事务成本一起被修复。pm_dispute_vote 过去通过在每个新选票上数市场的现有选票来强制选票 上限(遍历以 cap+1 为界):每事务有界,但每个选票 O(n),填满一个市场 O(n²),而且没有任何 cron 预算 覆盖的工作——与 M4 用 open_commits 从 commit 路径移除的反模式相同。计数现在住在 pm_dispute_object.ballots 上,在选票行创建时递增,在投票者修订一行时不动(修订覆盖该行,所以 计数器数的是行,不是票)。选票从不单独删除——GC 丢弃整个簇——所以计数器只增。
快照在这里需要 open_commits 没有的额外一步。争议在它们的选票之前被导入,所以一个 contains 保护的键读取无法单独修复缺字段的快照:reconcile_pm_dispute_ballots() 在选票导入之后 运行,让每个计数器与实际存在的行一致。它既播种旧快照(键缺失 → 0 → 重建),也抓住新快照中的 drift,代价是对一个导入反正刚遍历过的索引再走一遍。由 dispute_ballot_counter_matches_rows 覆盖, 它在每张选票之后把计数器与活的行数对照,并钉住修订路径。
4.7 小节顺序就是优先级顺序(cron §8,2026-08-19 发现,已修复)
process_pm_markets() 的每个小节都对着同一个 pm_processing_cap_per_block 收同一个计数器 done。这让小节顺序成为优先级顺序,这对做真实工作的扫描是刻意的——但这也意味着一个可靠耗尽 预算的小节把它后面的一切变成死代码。
第 7 小节,lazy-pool 召回步骤,正是这样一个小节。它每个区块从头部遍历 status-0 分配索引,并对 它访问的每一行收 done,包括那些它只检查并保持不动(idle, steps remain, but this step isn't due yet)的行。对检查收费是刻意的——正是这保持小节有界——但工作集又大又长寿:在测试网区块 82646702 上有 34 548 个 status-0 分配,对上 200 的上限。所以循环总是跑到 done == cap。
在它后面坐着第 8 小节,封禁过期扫描。它从未执行。临时的预言机和创建者封禁永远保持一个陈旧的 banned_until,且 pm_ban_expired 从未发射。损害有限:强制比较的是 banned_until 与 now,而 不是测试字段是否为空,所以没有账户在期满后仍被封锁——坏掉的是存储状态和历史事件,以及任何把 「被封禁」读作「字段非零」的客户端。在这成立期间测试网上没有封禁,所以没有任何可观察的卡住; 缺陷在于该小节根本无法运行。
修复给扫描自己的计数器(ban_done),而不是移动它或放大共享上限。这安全,因为扫描是自清理的: 一次访问把 banned_until 置 0,这永久地把该行从被扫描范围中移出。因此每区块工作是刚刚过期的封禁 数,而私有上限甚至约束一次同步的突发。
ban_expiry_survives_saturated_cron_budget 在微缩尺度上复现饥饿——cap 2、三个活分配来饱和它、一个 仍必须失效的短期创建者封禁。把扫描放回共享计数器上时,测试恰在那个断言上失败。
它留下的通用规则:附加到这个 cron 的新小节一到就死,除非它坐在第 7 小节之前,或携带自己的 预算。
4.8 lazy-pool 提现队列(per-tx,2026-08-19 发现,2026-08-20 修复)
service_lazy_withdraw_queue() 过去在每个调用上整体排干池子的 FIFO 提现队列——它循环到 free_balance 耗尽——而且它从六个地方被调用,其中四个在求值器内(存款、提现、杠杆平仓、杠杆转换) 外加 cron 里的两条资本返还路径。每队列行没有下限:pm_lazy_withdraw 在 owed > 0 时对每次部分 提现创建一个新的请求对象(一个 raw 就够了),同一账户的行从不合并。不对称之处在于,队列按每 事务一行被填满,而稍后由一笔无关的事务排干——在测试网 150 k VIZ 的 free balance 下,单个调用可以 支付多达 1.5 亿行。
修复(owner q#678=A):排干现在受预算约束。service_lazy_withdraw_queue(db, row_limit) 返回它处理 了多少行;每事务调用点传 1(只付 FIFO 头部——大部队由 cron 接走),而新的 cron 第 9 小节在 pending_withdrawals > 0 时把剩余排干到共享的每区块 pm_settle_rows_per_block 行预算为止。那个 小节是活性后备:即使没有资本返回 free_balance,队列也持续以每区块最多 row_budget 行推进,所以 它不会停转。cron 支付的行仍诚实地收共享预算(返回值从 row_budget 中减去)。
lazy_withdraw_queue_row_budget_spans_blocks 在微缩尺度上复现旧行为——250 个 one-raw 行对上 100 行的预算——并断言队列跨越多个区块、每区块 ≤ row_budget、FIFO 顺序、free_balance ≥ 0 和 pending_withdrawals → 0。用修复前的无界排干,对照在第一个区块排干全部 250 并因每区块边界失败。 无布局变化 → 测试网无需重新部署。
4.9 清算级联按每事务运行(2026-08-20 发现,开放)
以上所有都约束每区块的工作。cascade_liquidate() 打破了这个框架,因为它从求值器可达—— pm_place_bet(两个二元分支)、pm_cancel_bet 和 pm_withdraw_liquidity——所以它的成本按 事务支付,而一个区块能装下多少事务就有多少事务。
扫描本身的形态不可避免:每轮它遍历市场的 status-0 杠杆仓位,对着每个仓位的阈值评估 cancel_value(),在第一个受害者处停下。当没有可清算的——正常情形,也是注释描述为「廉价索引探测」 的那种——它仍会访问该市场上的每个开仓仓位才下结论说没有工作。pm_min_bet(1 VIZ)就足以触发 一次这样的扫描,而没有任何东西限制一个区块能携带多少下注。
被扫集合能长多大由池经济学而非任何显式上限固定。每个开仓仓位锁住至少 pm_min_liquidity (100 VIZ)的杠杆基金(#536 下限),基金是池 free balance 的 pm_leverage_fund_percent,而 free_balance 本身随着贷款发放而缩水,所以不动点大致是 N ≤ free / 1100(在今天 10% 下)。一个 持有约 11 M VIZ 的池因此支持约 10 000 个开仓仓位,而约束 3 只限制单个仓位的大小,不限制其中有多少 个共享一个市场。按实测约 1.7 µs 每访问行,那是一个 1 VIZ 的下注买下的约 17 ms 工作,区块内每个 下注都重复一次。
值得注意这个下限从哪来:pm_min_liquidity 是 #536 审计修复强加到贷款上的,而它自己的注释陈述了 意图——「把全局开仓数限制到 fund_total / pm_min_liquidity」。这个推理对按每区块衡量的工作 成立,那就是上面每一个扫描。它不适用于一个每事务跑一次的扫描:约束集合对集合被重走多少次什么也 没说,而且没有任何东西约束重走。
两条诚实的限定。第一,外循环在每次清算后从范围头部重扫(K 次清算为 O(K·N)),但 K 自阻尼: 清算一个仓位把它的代币卖回曲线,使价格朝向剩余的同侧仓位移动并让它们更安全,所以大规模级联 不是预期形态。每下注 O(N) 扫描才是那个不取决于任何事出问题的部分。第二,在测试网上这一切现在 都不可达:pm_leverage_max_per_position_bp(20 bp)对上当前基金使每仓位上限(约 30 VIZ)小于 100 VIZ 的贷款下限,所以根本开不了仓位——已知的 #536 冲突,owner 原样保留(q#568)。以利于小额 贷款的方式解开这个冲突会按比例加宽这个扫描;两个决策是耦合的。
决策(owner q#679=D,2026-08-20):不修。 杠杆是小众产品,暴露的集合经济上有上限(上面的 基金/池边界),而且攻击者反正要为级联必须扫描的每个仓位付抵押品和 funding。目标 #440 已关闭。
4.10 已检查并否决:开仓仓位扫描(cron §2c)
下注结束即强平仓位的那一小节,每个区块通过 by_lev_funding_due 遍历所有 status-0 仓位,并只 对平仓收 done,所以初读像是 §4.5 再来一遍:一个完整的扫描,其被跳过的行不花预算。
这不是同一个缺陷,而区别值得陈述,因为它是两个家族之间的分界线。在 §4.5,范围头部被永远不再 需要工作的市场填满,所以空闲扫描随周转量无界增长。这里每个被扫的行是一个活的义务——一条链最终 必须关闭的开仓贷款——而行在关闭那一刻(status 0 → 1)永久离开范围。因此工作集是与 §4.9 相同的 经济上有界的 N,而不是尸体积压。让它更便宜意味着在仓位上存一个强平截止时间并为其建键索引: 为一个已经受约束的集合上的常数因子收益做布局变更和快照迁移。不值得;记录下来好让下一次审计 不再重开它。目标 #441 以这个推理关闭。
4.11 批量执行器的空闲快速路径(cron §6,2026-08-20 发现,已修复)
§4.4 发布了按访问的每一行收行预算的批量执行器,但外循环仍是每个边界 O(active batch markets) 且不计量:一个空闲市场(当前时代无排队)在 LMSR q-vector 快照之前就跳过,什么都不付—— 没有 done,没有行预算——所以每边界扫描随市场数量线性增长,而不管实际存在多少队列。垃圾发送者 可以创建数千个 allow_batch 市场,把每个时代边界变成一次完整空闲遍历。
修复(P0):pm_bet 上的 by_status_market 索引——(status, market, id)——扫描现在直接从排队 (status=5)行驱动。只有真正持有队列的市场才会被访问;空闲市场从不进入范围。Resume 语义不变: 已执行行翻转 5 → 0/2 并离开索引,所以下一个 lower_bound((5, market, bet)) 自然落在下一未执行行 上,而两个动态属性游标(pm_batch_settle_cursor、pm_batch_settle_bet_cursor)保持其含义 (市场 / 停靠行)。一个防御性跳过跨过任何泄漏到非活跃市场上的 status-5 行——§2/H2 退款已经支付 它们,而该遍不得在计量遍和支付遍之间移动它们。目标 #445 已关闭。
4.12 递延权益清除与阶段 1/5 loud 信号(2026-08-20,已修复)
同一次评审的两个较小残余,与 P0 一起采纳(owner 选了 C):
P1 ——
purge_deferred_claims构建了每个递延权益的内存向量,删除前就构建,是refund_all_bets曾经携带(而 §4.3 已移除)的同一个参与者向量模式。权益已经被MAX_PM_DEFERRED_CLAIMS_PER_MARKET(10 000)封顶,所以这从来不是边界问题——但向量可以免费 丢掉:现在它在原地移除,在db.remove使其失效之前推进迭代器。目标 #446 已关闭。P2 —— 未计量的结算阶段 1 和 5。
settle_market_step只给阶段 2–4(下注行)做预算。阶段 1 (force_close_positions)和阶段 5(settle_liquidity)整体遍历市场的每个开仓仓位 / LP 行; 它们只受经济约束(每行 ≥pm_min_liquidity= 100 VIZ)。与其给它们计量——为一个 100 VIZ 下限 已经定价掉的威胁加游标和 resume——代码现在在其中一个遍历单步超pm_settle_rows_per_block时 发射一个loud 仅日志信号(镜像 F1 的uncovered信号)。它是确定性的且无共识影响;它存在 是为了让未来降低抵押品下限(例如把 #536 朝小额贷款解开)的变化让该阶段显眼地昂贵,而不是默默 劣化区块时间。目标 #448 已关闭。
5. 一行到底花多少
用 tests/consensus_sim/bench/settle_bench.cpp(make pm_settle_bench)测得,它把行数增长的 真实市场驱动过一条真实链,并计时结算它们的区块。Release 构建,无消毒器,无 account_history 插件:
| 行 | 空闲区块 | 结算区块 | 每行 | gc 区块 | gc 每行 |
|---|---|---|---|---|---|
| 500 | 0.28 ms | 1.24 ms | 1.93 µs | 0.59 ms | 0.62 µs |
| 2 000 | 0.29 ms | 3.61 ms | 1.66 µs | 1.54 ms | 0.62 µs |
| 8 000 | 0.29 ms | 13.95 ms | 1.71 µs | 5.50 ms | 0.65 µs |
结算随行数线性,约 1.7 µs/行,垃圾回收约 0.62 µs/行。外推:大约 600 000 行填满一秒钟的 区块时间,约 1.7 M 行填满整个三秒间隔。把这当作下界——基准从十个账户下注(真实市场有数千个, 所以查找更不缓存友好),而模拟节点不跑 account_history 插件,所以每行推入的虚拟操作在那里几乎不花 什么,而一个 API 节点要为索引它付费。
这就是单靠修复 A 的风险形态:一个有机市场远未到极限(测试网 734 行市场约 1.2 ms 结算完),但一个 愿意每行质押 1 VIZ 的垃圾发送者可以用约一百万 VIZ 在单个区块买下约 1.7 秒的结算工作。便宜到值得 关闭,这正是修复 D。
6. 调优
pm_settle_rows_per_block 在结算延迟与区块时间之间权衡,而上文的测量正是它应该据此设定的依据:
- 默认 2 000 每区块花约 3.4 ms 的结算工作(约区间的 0.1%),并在约 500 个区块、即半小时以内 排干一个百万行的市场;
- 提高到 10 000 每区块花约 17 ms,并在约 100 个区块内排干同一市场;
- 100 的下限存在,好让进展始终得到保证。
提高 pm_min_bet 反而缩短队列,代价是排除小额下注者——优先先调预算。
7. 已封堵的攻击向量
攻击向量是:垃圾操作 → 区块工作量无限增长。区块的动作池是有限的,而预测市场 cron 是唯一一个 廉价单笔操作可以无上限地放大区块必须完成的工作量的地方。每个可能放大该工作量的路径现在都已受限:
- 创建行有成本。
pm_place_bet要求pm_min_bet(1 VIZ),追加流动性要求pm_min_liquidity(100 VIZ),部分pm_transfer_position不再免费拆分行。 - cron 的每次清扫都按行计量。 结算(第 2–4 阶段)、垃圾回收、作废退款、批量执行器(§6)、 争议计票(§4)、封禁到期(§8)和惰性提款队列(§9)都从共享的
pm_settle_rows_per_block预算中 扣减。 - 清扫走索引而非全表扫描。 截止时间清扫(§4.5)和批量执行器的空闲路径(§4.11)都基于带状态 的索引,因此空闲或已结算的市场不产生任何成本。
- 剩余的未计量遍历要么定价、要么发信号。 结算第 1 和第 5 阶段(
force_close_positions、settle_liquidity)会整市场遍历,但它们触及的每一行成本不低于pm_min_liquidity(100 VIZ); 若单步超出预算则发出响亮的日志信号(§4.12)。清算级联(§4.9)按交易运行,其规模由杠杆基金决定 而非上限,且因 #536 门槛冲突目前不可达。 - 硬上限封堵其余部分。
MAX_PM_DEFERRED_CLAIMS_PER_MARKET(10 000)、pm_dispute_votes_per_market(100 000,受 1000 VIZ 质押门槛约束)、MAX_PM_OPEN_COMMITS_PER_MARKET(10 000)限制了跨市场生命周期存续的参与者向量。
结论:攻击者仍然可以支付真金白银让链做真实的工作,但每个区块的工作量受行预算约束,每行的成本不低于 抵押门槛。不再存在廉价单笔操作在单个区块内买到无界工作的路径。