Skip to content

feat(CC-98): production per-proposal committee BLS validator - #237

Merged
jhfnetboy merged 11 commits into
masterfrom
feat/cc98-committee-validator
Aug 17, 2026
Merged

feat(CC-98): production per-proposal committee BLS validator#237
jhfnetboy merged 11 commits into
masterfrom
feat/cc98-committee-validator

Conversation

@jhfnetboy

@jhfnetboy jhfnetboy commented Aug 17, 2026

Copy link
Copy Markdown
Member

Status: DRAFT / HELD — two design-level Criticals escalated (CC-98)

pr-daemon (4-round PK) found two blocking issues Codex's 3 rounds missed. Both are design/cross-repo, not unilaterally fixable by dvt — escalated on CC-98, PR held:

  • B1 (Critical) — the sortition curve never requires an honest signer: an attacker holding β of the pool forges with only its OWN ⌈2m/3⌉ selected nodes. m ≤ 110 caps the tail at ~6.6e-6 (no n reaches 1e-9; worst point n=84 = 3.5e-2). Needs DSR's written security target + corrected m/T curve.
  • B2 (Critical)accountId is calldata-trusted; the account-injection invariant is cross-repo and airaccount chore(deps): bump @nestjs/core from 11.1.27 to 11.1.28 #200 is draft/HOLD → no interlock (flipping the switch first makes attacker-crafted legacy-shaped payloads parse as valid committee payloads). Needs airaccount interlock + per-account enrollment.

Self-contained pr-daemon findings are fixed in this branch (B3 setOversample configVersion, requiredQuorum default-config sentinel, syncNode/snapshot atomicity, window floor, slot events, discriminating look-ahead + syncNode coverage tests). 105 tests pass.


What

Production on-chain instantiation of the CC-98 per-proposal random-committee model (airaccount-contract + dvt + DSR). Replaces CC-97's global ⌈2N/3⌉ (unbounded registry → quorum exceeds the single-call node cap → network-wide fail-close).

AAStarCommitteeValidator is AAStarValidator — reuses all BLS / RFC-9380 / pairing crypto; adds the committee subsystem: incremental sparse-Merkle active-set commitment (TREE_DEPTH=14), snapshotEpoch() double-snapshot with one-epoch look-ahead (set frozen strictly before the seed is revealed → closes register-to-order), per-signer Merkle+sortition in validate() (zero extra pairings), DSR m/T curve.

Wire format (account prepends accountId; submitter provides the rest):

signature = [accountId(32)] [ per signer: nodeId(32) | slot(32) | merkleProof(TREE_DEPTH*32) ]... [ blsSig(256) ]

(slot = the node's leaf index in the FROZEN setRoot[e-1], authenticated by the proof.)

Review trail

  • Codex Tier-1 (3 rounds): APPROVED — but missed B1/B2 (soundness/cross-repo).
  • pr-daemon (4-round PK, Opus-adjudicated): B1/B2 Critical (escalated), plus self-contained findings (fixed here).
  • Self-review (4 rounds): fixed the m_e-cap liveness DoS + whole-set edge.

Tests

105 total pass (committee suite includes mutation-verified look-ahead + hook discriminators).

NOT in scope / blocked

cc CC-98.

AAStarCommitteeValidator extends AAStarValidator with the converged CC-98
model: per-operation random committee the submitter cannot choose.

- Incremental sparse Merkle commitment of the active set (TREE_DEPTH=14),
  maintained O(depth) via _onNodeActivated/_onNodeDeactivated hooks added to
  the base at every set mutation (register/dereg/revoke/batch).
- snapshotEpoch(): permissionless double-snapshot. seed[e]=blockhash(e*L)
  pinned within the 256-block window; setRoot[e]=runningRoot frozen for use
  ONE EPOCH AHEAD (look-ahead) so the committed set is frozen strictly before
  the seed is revealed -> closes the register-to-order grinding window.
- validate(): per signer verifies Merkle membership in setRoot[e-1] +
  sortition H(CMT_SELECT, seed[e], accountId, nodeId) < T, zero extra
  pairings, then the base aggregate BLS verify. accountId is account-injected
  (root of trust). Threshold requiredQuorum = ceil(2*m_e/3), m_e per the DSR
  curve capped at n. epochLength==0 -> legacy whole-set fallback.

Self-review (4 rounds) fixed two real bugs before commit:
  1. expectedCommittee floor-16 not capped at n -> 9..15 pools had
     quorum > n (unsatisfiable, liveness DoS). Now capped at n.
  2. whole-set (T=max) spuriously excluded a draw==max node (2^-256).
     Now skips the draw when T==max.

18 committee tests + 96 total pass. Base changes are additive virtual hooks
(no behavior change to the legacy validator; all existing tests green).
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@jhfnetboy
jhfnetboy requested a review from clestons August 17, 2026 02:10
Codex Tier-1 review (via codex exec) of PR #237. Code fixes:
- High: setEpochLength rejects epochLength==1 (startBlock==block.number =>
  never pinnable => committee mode permanently dead). Now requires 0 or >=2.
- Medium: submitter now provides the signer's slot in the payload,
  authenticated by the Merkle proof against the FROZEN setRoot[e-1] (each
  nodeId is at one slot in that tree, so a wrong slot cannot verify). Fixes
  the slot-reuse liveness edge where a re-registered node's live slot no
  longer matched its historical slot. Wire adds slot(32)/signer.
- Medium: setOversample bounds num/den ([1,8], den<=1e9) so oversampleNum*m
  in _thresholdOf cannot overflow and turn a fail-closed return-1 into a
  revert (uniform malformed-op handling preserved).
- Low: configVersion namespaces epoch snapshots; setEpochLength bumps it so a
  seed/root pinned under a previous schedule is never reused under a new one
  (validate + requiredQuorum fail-closed until re-pinned).

Documented as SECURITY ASSUMPTIONS (residuals inherent to the architecture,
not code-fixable here):
- accountId is account-injected (router breaks msg.sender binding) — a
  load-bearing account-side invariant (airaccount MUST enforce).
- frozen-member removal is a BFT liveness bound (DSR oversampling + SP
  unbonding + epoch retry mitigate; lowering quorum on churn would break
  safety, intentionally not done).
- missed pin window => <=2-epoch self-healing outage (no grindable late-seed
  fallback); run redundant permissionless keepers.
- caller-chosen freeze time within the pin window is minor fairness griefing,
  not safety (seed still unknowable at freeze).

22 committee tests (4 new for the fixes) + 99 total pass.
…1 fixes)

- round-2 High: snapshotEpoch allows re-pinning an epoch whose pin belongs to
  a previous configVersion (else colliding epoch numbers after an epochLength
  change strand committee mode until the counter passes every old ID).
- round-2 Low: validate rejects non-canonical slot (slot >= 2^TREE_DEPTH),
  which _verifyMerkle would otherwise alias to slot mod 2^TREE_DEPTH.

Codex Tier-1: APPROVED - no remaining findings (3 rounds). 24 committee + 99 total pass.

@clestons clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ REQUEST_CHANGES — 7d91f963 首审 [4-round]

实现是对的,方向也是对的 —— 挡下这个 PR 的不是 bug,是两条它自己写下来但没有兑现的安全声明。

#235 那条「committee 无上界但 quorum 有」的不可满足死锁,这一版真的关掉了m ≤ 110requiredQuorum ≤ ⌈220/3⌉ = 74 < MAX_NODE_COUNT(100),任何池子大小都可满足。look-ahead 的论证也成立 —— snapshotEpoch()e = block.number / epochLength 永远解析成当前 epoch,所以 setRoot[e] 必然冻结在 epoch e 之内,严格早于 seed[e+1] 存在的那一刻。


✅ 先说实测过关的

forge test @7d91f963 = 101 passed / 0 failed (8 suites)
hook 穷举: 基类 6 处 isRegistered[] 写入,6 处都有配对 hook
  841/843 registerPublicKey · 883/885 registerWithProof · 943/950 _deactivate
  1015/1016 revokePublicKey · 1054/1056 batchRegister      ← 一处不漏
五个变异精确变红:
  删 sortition draw 判断        → RED (rejects_wrong_accountId_committee)
  删 _verifyMerkle              → RED (tampered_proof, wrong_slot)
  删 `if (m > n) m = n`         → RED (expectedCommittee_curve, liveness_midsize_pool)
  删 `if (k < required)`        → RED (rejects_below_quorum)
  删 canonical-slot 上界        → RED (rejects_noncanonical_slot)

并且 if (m > n) m = n 这一行比我会开的处方好,这一点值得单独说。 面对 #235 那个 bug,评审者的自然处方是夹住症状:required = min(required, n)。那是有害的 —— n=12 时它留下 m=16,给出 required = 11,即92% 的池子必须签名,而且这个阈值对 9..15 区间里任何 n 都不等于 ⌈2n/3⌉。你夹的是上游的 m,同时喂给 _quorumOf(m)_thresholdOf(n,m) 两个消费者:required 变回 ⌈2n/3⌉(n=12 → 8),且 target = ⌈1.15n⌉ ≥ n ⇒ T = max,干净地退化成「全集 + 正常 2/3 阈值」。一行,一个不变量,两个消费者都对。

configVersion 那条重签规则(:215-217)同理:朴素写法(require(!epochPinned[e]) + 版本检查)会砖化 —— 改完 epochLength 之后,与旧 pin 编号相撞的新 epoch 永远签不上。你把「陈旧本身」当成重签许可,schedule 内不可变、跨 schedule 可重建。我查了最明显的反驳并且它不成立:因为 e 只能是当前 epoch,重签仍然发生在 seed[e+1] 存在之前,look-ahead 完好。


🔴 Blocking

B1 AAStarCommitteeValidator.sol:252-265,:19-30 —— 出厂的 sortition 参数在任何池子大小上都达不到 header 自己声称的安全性

validate() 从不要求任何一个诚实签名者:每个提交的签名者只需 registered + 在冻结集合里 + 过 draw,然后 k >= required,然后对提交集合做聚合 BLS 验签。所以持有池子 β 份额的攻击者,只要自己的 ⌈2m/3⌉ 个节点过 draw 就能伪造。精确二项式(我和 R2/R4 三次独立算出同样的数):

β = 1/3:  n=45 →1.49e-2   n=60 →2.58e-2   n=80 →2.82e-2   n=84 →3.54e-2 ← 最坏点
          n=100→1.03e-2   n=200→8.95e-4   n=550→1.30e-7
β = 0.20: n=80 →1.74e-4

而且它不是「每次赌一把」,是「按账户收割」:draw = H(CMT_DOMAIN, seed[e], accountId, nodeId)没有 op hash,全部是公开状态。快照一落,攻击者离线枚举每一个账户,只打自己已经占优的那些。

更要命的是它不随网络增长改善,是先变差再持平m ≤ 110target 钉死在 127,于是 E[攻击者被选中数] = β·127 ≈ 42.3 与 n 无关。n=550→1.30e-7,n=2000→2.82e-6,n=20000→6.13e-6,收敛到 Poisson(42.33) 在 74 处的尾 6.64e-6没有任何池子大小能让这个设计好过 6.6e-6 / (账户·epoch)。

而 header :30 写的是「reduces the attack to holding a β-fraction of ALL stake (the honest-majority assumption the DSR tail bound is built on)」—— 用它自己出厂的参数,这句话不成立。

(i) 不变量:对每个 n 和每个 (账户, epoch),β=1/3 的攻击者伪造概率必须低于项目明写的安全目标,且该概率随 n 单调不增
(ii) 必须满足它的路径:部分抽样带 n≥20(峰值 n=84)、m=110 封顶带 n≥550、全集退化分支 target>=n(n≤19,这一段是健全的)。
(iii) 每条路径一个测试n∈{20,84,150,550,2000} 的尾概率表格测试(断言链上 expectedCommittee/_quorumOf/_thresholdOf 三元组对得上离线表);一个单调性测试断言尾概率随 n 不增 —— 它今天在 550→2000 上就是红的,那是这条 finding 最锋利的表达;n≤19 的全集 2/3 测试。
(iv) 错误修法的反向危害:把 m>110 抬到 148(MAX_NODE_COUNT=100 允许的上限,quorum 99)是最诱人的修法,错两次 —— 只买到约 100×(仍在 1e-8 量级,仍是渐近线),99 签名者 × 512B ≈ 50KB calldata/op,而且它完全碰不到 n∈[45,150] 这一段(那里 m 由 max(16, n/5) 的下限决定,根本到不了 cap)。花掉全网 gas 和活性,最坏点 n=84 原地不动。
我认可 R2 的方向 —— 把全集带从 n <= 8 抬到 n <= 150(那正好既是抽样最不安全的一段,又是全集仍装得下 100 签名者上限的一段:n=150 → ⌈300/3⌉ = 100)—— 但要带一条修正:它在 n=151 留了个悬崖(一个节点之差,0 → 6.4e-3),必须和抬高 n/5 下限(或一条明写的「committee 模式要求 n ≥ N₀」闸门)一起上,不能单独发。诚实的代价:n ≤ 150 时中型池回到 CC-97 的活性画像(每笔 op 要 2/3 的全部节点在线)。

B2 :296,:333 —— accountId 是安全关键、取自 calldata,而唯一的执行者是一个跨仓库约定,且对家 PR 现在是 draft/HOLD

bytes32 accountId = bytes32(signature[0:32]);。header 承认了这一点并给了理由(中间有 ValidatorRouter,绑不了 msg.sender)——这个理由是对的,而且比它写的更强:真去绑 msg.sender,全网每个账户都塌缩到 router 那一个地址,于是整个网络一个委员会、提前一个 epoch 公开可知,比现状严格更糟

但「账户侧会注入」现在没有任何东西保证:committee 模式由 owner 的 setEpochLength 单方面打开,而 airaccount-contract#200(账户侧)此刻是 draft + HOLD,本 PR 自己也写着账户侧集成 not in scope、payload unchanged。开关和前提在两个仓库里,中间没有联锁。

Codex 把翻转顺序的后果算得比我们细,值得原样引用:legacy payload 长度 L = 256 + 32sbody = 32(s-1),要过 body % 512 == 0(s-1) % 16 == 0,即 s ∈ {17,33,…,97} ⇒ k = 1..6。所以先翻开关的后果是两件事同时发生:诚实的 legacy 流量整体 fail-closed(DoS),而攻击者精心构造的 legacy 形状 payload 会被解析成合法 committee payload —— 也就是 accountId 采购伪造当场生效,不只是 DoS。

(i) 不变量:折进 draw 的 accountId 必须等于「本次操作所属账户」的地址,对每一种调用者和每一种挂载拓扑都成立。
(ii) 路径:账户→validator 直连;账户→Router→validator;任意第三方直接调 validate() 带任意 32 字节前缀。
(iii) 每条路径一个测试:直连时提交者自选 accountId 被拒;Router 路径下折进 draw 的不是 router 地址;一个「委员会采购」测试 —— 在 seed[e] 落定后 grind accountId,断言 grind 不成立。
(iv) 错误修法的反向危害accountId = bytes32(uint256(uint160(msg.sender))) 是最显然的修法,也是这里破坏力最大的一个改动 —— 它把一个概率性的单账户攻击换成确定性的全网攻击。正确形状是 R2 的逐账户 enrollment 映射:账户自己直连 validator 注册一次(enroll 时 msg.sender 就是账户本身),validate() 里查表。它挡不住 pass-through bug 本身,但把「对 2^256 个 accountId 的免费离线 grind」换成「对已注册地址的、按 gas 计费的 grind」。
这条在本 PR 内部修不完 —— 这正是它不能在对家还是 draft 时合入的原因。

B3 :150-162 + :274-279 —— setOversample 一次调用就能追溯地关掉整个 sortition 门,对已经 pin 好的 epoch 生效,且不 bump configVersion(R4 挖出,我独立复现)

我写了一次性探针实跑(跑完即删,git status 干净),每一条断言都成立:

80 节点池,epoch 1/2 已 pin, m=16 target=⌈1.15·16⌉=19 < 80 ⇒ sortition 是活的
挑 11 个【不在 ACCOUNT 委员会里】的节点(draw >= T)构造 payload
  validate() = 1                              ← 之前:正确拒绝
v.setOversample(5, 1)   // den!=0 ✓  num>=den ✓  num<=8*den ✓ —— 完全在 setter 自己的界内
  configVersion 不变 ✓   epochPinned(1)/(2) 仍 usable ✓
  _thresholdOf(80,16) = type(uint256).max      ← sortition 整个被跳过
  validate() = 0                              ← 之后:【同一批局外节点】通过了
  requiredQuorum() 仍是 11 = 池子的 13.8%,而且不再有任何 draw

危害带是结构性的不是角落m = n/5target = ⌈over·n/5⌉ >= n任何 ≥5 的 oversample 比值成立,覆盖整个 80 ≤ n ≤ 550;over=8 时一直到 n=880。n=2000 时同样的塌缩会把确定性下限放到 74/2000 = 3.7%

不对称本身就是判据setEpochLengthconfigVersion += 1,正是为了让重配置不能套用到旧 pin 上;而 setOversample 改的是委员会定义的另一半,不 bump。

(i) 不变量:一个 epoch 一旦 pin,其委员会定义即不可变;且 setOversample 接受的任何值都不得把 sortition 门塌缩成全集。
(ii) 路径_thresholdOftarget >= n 分支;validate()oversampleNum/Den实时读取(用于一个更早参数下 pin 的 epoch);requiredQuorum() 同样的实时读取(账户侧镜像检查信的就是它)。
(iii) 每条路径一个测试:断言当 ⌈num·m/den⌉ >= 当前 epochSetCountsetOversample revert(或 bump configVersion);断言 setOversample 前无效的 payload 不会在同一 epoch 内被变成有效;断言对已 pin 的 epoch,requiredQuorum() 跨 oversample 变更保持稳定。
(iv) 错误修法的反向危害:只把界收紧到 num <= 2*den 看着够用,并不够 —— 它把追溯性原样留着,将来任何一次放宽这个界就会悄悄重新打开同一个洞;而且它仍然允许 owner 在 epoch 中途平移 target,让所有在途聚合器的计算作废(一个没有任何报错的活性 DoS)。主修是给 setOversampleconfigVersion += 1(与 setEpochLength 对称,改动 fail-closed 并强制重签),收紧界是次要的。


已确认(非阻塞)

  • [Medium] :306-313 + 基类 :885/:947 —— 三个变异存活,而且恰好全落在生产配置上
    setRoot[e-1] → setRoot[e](整个 look-ahead 删掉)      → 101/101 全绿 ⚠️
    删 _deactivate 里的 _onNodeDeactivated                → 101/101 全绿 ⚠️
    删 registerWithProof 里的 _onNodeActivated            → 101/101 全绿 ⚠️
    
    不是三个洞,是同一个洞_payloadgetMerkleProof()当前根建证明,所以没有任何测试让冻结树和活树不一致(look-ahead);committee 套件全部用 bootstrap 的 registerPublicKey 注册、用 revokePublicKey 注销(后者有自己的直连 hook :1016),于是 registerWithProofrequireStake=true 后唯一的注册路径)和 _deactivate/syncNode(唯一的自动注销路径)从没碰过 SMT
    代码今天是对的 —— 我写了 25 行探针(epoch-1 pin 与 epoch-2 pin 之间注册第 4 个节点,然后在 epoch 2 用它):当前代码 PASS(rc=1,正确拒绝),删掉 look-ahead 后 FAIL(rc=0,被接受)。所以这是覆盖问题,缺的测试只要 20 行

    反向危害:让 _payload/getMerkleProof 去重建冻结根,会让测试变绿却仍然不是判别式 —— look-ahead 变异照样存活。测试必须断言一个排除(冻结后注册的节点被拒),不是一个证明形状。同样错的是只在基类套件里加 hook 断言 —— M8 就藏在那里。

  • [Medium] :278 —— requiredQuorum() 先调 currentEpoch()(它 require(epochLength != 0)),在自己的哨兵分支之前,所以 epochLength == 0 时它 revert 而不是返回 type(uint256).max。这与 :276-277 自己写的「fails closed」和 :369-371 的「no-op vs legacy」直接矛盾 —— 而 epochLength == 0 正是默认配置。账户侧若直调则每笔 op revert,若 try/catch 则静默丢掉这道检查。:直接读 epochLength,为 0 时返回哨兵。反向危害:返回 0 会让账户的 k >= quorum 镜像检查恒真(fail-open)。
  • [Medium] :215-241 + 基类 :927 —— syncNode(无 owner 检查,直达 _deactivate)和 snapshotEpoch(permissionless)可以在同一笔交易里组合:批量剔除、紧接着冻结。压低 epochSetCount[e] ⇒ 压低 m 和 required ⇒ 抬高攻击者的有效 β,直接喂进 B1。便宜的修法:两个 hook 里记 lastSetMutationBlocksnapshotEpochrequire(lastSetMutationBlock < block.number),把一个必然成功的原子操纵变成一场诚实 keeper 能赢的竞速。反向危害:在 snapshotEpoch 里加 require(activeCount >= MIN_POOL) 会在网络收缩时让 committee 模式彻底签不上 —— 永久砖化,比这个攻击更糟。
  • [Medium] 基类 setRequireStake vs header :44-47 —— header 把「SP unbonding delay 限制了大规模退出」记在活性假设里,但 setRequireStake(true) 一次 owner 调用就瞬间让每一个 bootstrap 节点在 validate() 里失效(requireStake && isBootstrap → return 1),没有 _onNodeDeactivated,于是 activeCount/epochSetCount 继续把它们算进去。bootstrap 占多数的池子上,这是一个保证 >1/3 的瞬时退出。反向危害:在 setRequireStake 里循环给每个 bootstrap 节点补 hook 是无界循环 → owner 被 gas DoS;正确做法是在快照处把已退休 bootstrap 排除出 activeCount,或要求先用 syncNode 分批退休再翻开关。
  • [Medium] header :6,:9-11 —— 「PER-PROPOSAL」名不副实。draw 里没有 op hash,委员会是 per (账户, epoch)、整个 epoch 固定且公开可枚举。这正是把 B1 从「每笔赌一把」变成「按 epoch 收割」的那一步。 反向危害:把 op hash 折进 draw 会把委员会选择交给提交者(nonce/gas 字段可 grind),严格更糟 —— 改句子,不要改 sortition
  • [Low] :225 —— require(block.number <= startBlock + 256)epochLength <= 257永远不可能触发1 ≤ r ≤ epochLength-1 ≤ 256)。真实窗口是 min(256, epochLength-1)epochLength = 2 时只有一个区块,错过一次代价是两个 epoch。反向危害:允许补签过去的 epoch 会重新打开 register-to-order —— setRoot[e] 将在 seed[e+1] 已可知之后才冻结,本 PR 的核心性质当场作废。安全的方向是反的:require(_epochLength == 0 || _epochLength >= 64) 并把注释改成真实窗口。
  • [Low] :141-152 —— configVersion bump 会作废 e-1 的 pin,而 e-1 永远补签不回来(只有当前 epoch 可签),所以每一次 setEpochLength(含首次启用)之后 committee 模式至少停摆一整个 epoch。这是上面那条「重签规则比处方好」的真实代价:请写进 header 的 LIVENESS 假设,不要去工程掉它。
  • [Low] :397-413 —— getMerkleProof 只出当前根的证明,而没有任何事件带 slot,聚合器必须逐字重实现空闲槽分配器(LIFO freeSlots + 单调 nextSlot + slotPlusOne != 0 提前返回)才能重建冻结树,分配器一改就静默失效。:hook 里发 SlotAssigned/SlotCleared
  • [Info] :56 —— G1_LEN 声明了没用。
  • PR body 与代码不一致:body 把线格式写成 nodeId(32) ‖ merkleProof(TREE_DEPTH*32)漏了 slot;header :32-34perSigner = 64 + TREE_DEPTH*32 = 512:314)才是对的。照 body 实现的聚合器会发 k·480 字节,480 % 512 != 0,在 :317 全军覆没且零诊断。另:body 说「18 committee tests + 95 total」,实际是 24 + 101

驳回

  • R1a-2(_thresholdOf 溢出) —— setOversample 界住 num ≤ 8·den ≤ 8e9m ≤ 110≤ 8.8e11,不可能溢出。(这个 setter 的真问题和溢出方向相反 —— 见 B3。)
  • R1a-3(_quorumOf 在 m=0) —— 逐行追踪:committedCount==0 ⇒ m=0 ⇒ required=0k<required 确实不触发,但 T=_thresholdOf(0,·)=0T != max,于是每个签名者都撞上 draw >= 0 返回 1。fail-closed,正确。
  • R1b-1/2/3 —— 三条是同一条 accountId 问题,不是三个发现。
  • 「聚合 BLS 验签或某道注册闸门会强制至少一个诚实共签者」(我们给 R4 的最强反驳) —— 证伪:_validateBLSSignatureMem 聚合的是恰好提交的那批 nodeIds 的公钥;isRegistered 攻击者的节点满足,requireStake && isBootstrap 攻击者的非 bootstrap 节点也满足。全链路没有任何一层要求一个非攻击者签名。
  • 「β=1/3 定价错了,因为注册是许可制」 —— 部分成立但不构成崩塌:ROLE_DVT + 30 ether GToken minStake 是真实成本,且 n=80 时 β 低于约 0.14 攻击结构上不可能(节点数不够凑 required);但 registerWithProof 自己写着 permissionless-but-staked,成本可跨地址并行,而合约 :30 正是按这个假设定价的。
  • 我自己对 look-ahead 的怀疑 —— 证伪:snapshotEpoch() 只能 pin 当前 epoch,所以 setRoot[e] 必然冻结在 seed[e+1] 存在之前。look-ahead 论证成立,问题只在它没有测试守着。

建议

  • 两条 High 按「能解锁评审」的顺序修,不按发现顺序accountId enrollment 映射(R2 的设计)可以独立合入,不用等 airaccount 那个 draft;而 sortition 曲线得先有一个明写的安全目标才能修 —— 没有目标就没有「修好了」的判据,而 6.64e-6 的渐近线意味着这个构造下没有任何 n 能到 1e-9
  • 把那 25 行 look-ahead 探针原样收进套件,再加一个 requireStake=true 版本的 committee 套件。 这两笔一起杀掉全部三个存活变异,而且探针在当前代码上本来就是绿的 —— 只花覆盖率,不花别的。
  • setOversampleconfigVersion += 1(与 setEpochLength 对称);PR body 的线格式补上 slot,并加一条 golden-vector 解码测试,让线格式有一个机器可校验的唯一真相。

各轮结论

轮次 模型 结果
R1a deepseek-v4-flash 3 条中 1 条真(accountId 绑定,High);溢出与 m=0 两条被界和 fail-closed 追踪各自驳回
R1b deepseek-v4-flash(安全) 3 条全部是 R1a-1 的复述,本轮零边际贡献
R2 Opus 独立评审 本轮的主干:B1 的精确二项式、「绑 msg.sender 比 bug 更糟」的 router 论证、enrollment 替代方案,以及「三个存活变异是生产配置上的同一个洞」的正确诊断
R3 Codex PK(真跑 codex exec,gpt-5.6-sol,self-check grep 命中) 6/6 CONFIRM、零挑战。作为 PK 轮不够对抗,但它实质性地锐化了 B2L=256+32s ⇒ body=32(s-1)s ∈ {17,…,97} 时攻击者构造的 legacy 形状 payload 会被解析成合法 committee payload —— 翻转顺序的窗口是活的伪造面,不只是 fail-closed DoS。这一条 R1/R2 都没有
R4 Opus 裁决 主动证伪 B1 的四条反驳全部失败,B1 反而加强(最坏点是 n=84 的 3.54e-2 而非 n=80;安全性从 n=550 到渐近线是变差而不只是持平);自己重跑了两个变异;新挖出 B3 setOversample 追溯塌缩并给了实证

机械证据:worktree HEAD 7d91f963gh pr view 报的一致;forge test 101/101;基类 6 处 isRegistered[] 写入与 6 处 hook 逐行核对;8 个变异(字面量替换 + assert old in s,逐个 git checkout -- 还原,末尾 git status 干净)5 红 3 存活;25 行 look-ahead 判别探针在当前代码 PASS、在变异下 FAIL;setOversample(5,1) 追溯塌缩探针每条断言实测通过(11 个局外节点 rc 从 1 变 0,configVersion 不变);B1 的二项式尾概率在 n∈[20,1200] 全扫独立复算(峰值 n=84 = 3.54e-2,n=550/2000/20000 = 1.30e-7/2.82e-6/6.13e-6,Poisson 渐近 6.64e-6);⌈2m/3⌉ ≤ 74 < MAX_NODE_COUNTtarget >= n 塌缩带(over≥5 覆盖 80≤n≤550)逐点核算。

PR-Daemon v4 · 4-round PK pipeline · 结论由 Opus 裁定

@jhfnetboy
jhfnetboy marked this pull request as draft August 17, 2026 03:21
…d, held)

pr-daemon RC on #237 found two design-level Criticals that dvt cannot fix
unilaterally -- escalated on CC-98, PR held draft:
  B1: sortition curve never requires an honest signer + m<=110 caps the tail
      at ~6.6e-6 (no n reaches 1e-9). Needs DSR's written security target and
      a corrected m/T curve.
  B2: accountId is calldata-trusted and enforcement is cross-repo while
      airaccount #200 is draft -- no interlock; needs airaccount + enrollment.

This commit fixes the SELF-CONTAINED findings (curve-independent, all correct
regardless of how B1 resolves):
- B3 (High): setOversample now bumps configVersion (symmetric with
  setEpochLength) so it cannot retroactively collapse the sortition gate on an
  already-pinned epoch. Test proves a valid op fails-closed after the change.
- Medium: requiredQuorum() reads epochLength directly instead of via
  currentEpoch(), so the default epochLength==0 config returns the fail-closed
  sentinel instead of reverting.
- Medium: snapshotEpoch requires lastSetMutationBlock < block.number, so a
  permissionless syncNode eviction cannot be atomically composed with the
  freeze to depress epochSetCount (feeds B1's budget).
- Low: setEpochLength floor raised to 64 (real pin window is
  min(256, epochLength-1); tiny values leave no keeper window).
- Low: emit SlotAssigned/SlotCleared so aggregators reconstruct frozen trees
  from logs without re-deriving the slot allocator.
- Info: drop unused G1_LEN.
- Coverage: added a DISCRIMINATING look-ahead test (verified: fails on the
  setRoot[e-1]->setRoot[e] mutant) and a syncNode->_deactivate SMT-hook test
  (verified: fails on the hook-deletion mutant). registerWithProof-path hook
  coverage still open (needs stake/PoP mock) -- deferred to the post-B1 pass.

105 tests pass. #237 stays draft pending B1 (DSR) + B2 (airaccount).
…ve view + migration interlock

airaccount locked the B2 account-side design (f444db89): accountId injection
(account-side, mandatory) + these dvt-side pieces (curve-independent, land now):

- committeeActive() view: the account reads validator mode to pick its
  signature framing instead of guessing from payload shape (the shape-collision
  root of the flip-order attack). Same state drives parse + framing => no desync.
- enroll()/unenroll() + enrolledAccount mapping (self-proving: msg.sender IS
  the account). validate() fails closed unless the injected accountId maps to an
  enrolled address. This (a) blocks the flip-order legacy-shaped payload on-chain
  (fabricated accountId prefix -> non-enrolled -> reject) and (b) turns a free
  offline grind over 2^256 accountIds into a gas-metered grind over enrolled
  addresses. Defense-in-depth, NOT a replacement for account-side injection.
- setEpochLength migration-interlock doc: deploy+mount+enroll injecting accounts
  BEFORE flipping committee mode.

Does not touch the sortition m/T math -> independent of B1 (still on DSR).
31 committee tests (4 new: committeeActive, enroll self-proving, unenrolled
fail-closed) + 107 total pass.
… blocker)

DSR delivered the security target and curve (CC-98 9a9f47c9, Jason-adjudicated):
target ε = P(Poisson(β*m_e) >= ceil(2*m_e/3)) <= 1e-6 per (account, epoch)
under β <= 10%; N>=430 meets 1e-6 even at worst-case β=1/3.

Curve: m_e(N) = N (N<=8); else clamp(ceil(N/5), 17, 86).
- floor 16 -> 17 (16 gave ε=1.02e-6, just over; 17 gives 2.6e-7)
- cap 110 -> 86 (86 is the β=1/3 1e-6 point; 110 was over-provisioned)
- floor(N/5) -> ceil(N/5)
- oversample 1.15 -> 1.0: DSR's calc assumes E[committee]=m_e (λ=β*m_e); any
  oversample>1 inflates λ and breaks the N>=430 β=1/3 razor-thin margin.

Independently verified the whole ε table with an exact log-space Poisson tail
(matches DSR row-for-row: N=20 2.55e-7, N=100 2.93e-8, N=150 8.3e-11,
N=300 4.8e-20, N=430 β=1/3 9.77e-7). Sampling regime (N>=17) meets ε<=1e-6;
the N<=16 bootstrap band is whole-set, governed by the structural 2/3-of-nodes
argument (impossible at β<=10%) — airaccount's N0 gate keeps small pools off
the sampling path.

Added pr-daemon's B1 discriminators: a DSR-table triple test (m_e,
requiredQuorum) and a monotonicity test (m_e non-decreasing in N => ε
non-increasing, closing 'security worsens as the pool grows').

110 tests pass.
@jhfnetboy
jhfnetboy marked this pull request as ready for review August 17, 2026 05:59
@jhfnetboy
jhfnetboy requested a review from clestons August 17, 2026 05:59
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@clestons clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ REQUEST_CHANGES — 6f8daa4d 增量复审(第 2 轮)[4-round]

上一轮三条 Blocking 都真修好了 —— 不是绕过,是从根上换了做法。 但 B1 的修法是单尾的:把伪造尾压到 1e-6 的同时,把诚实活性尾放大到了 6.3%,而这一侧 PR 里一个数字都没有。


✅ 先确认修好的(实测)

上一轮存活的三个变异,现在两个变红;新加的守卫全部有测试钉住:

forge test = 110 passed / 0 failed  (上轮 101)
look-ahead setRoot[e-1]→[e]          → RED (test_validate_lookahead_rejects_post_freeze_registrant) ← 上轮存活
删 _deactivate 的 _onNodeDeactivated  → RED (test_syncNode_updates_smt)                            ← 上轮存活
删 enrollment 检查                    → RED (test_validate_rejects_unenrolled_account)
删 setOversample 的 configVersion bump → RED (test_setOversample_invalidates_pinned_epochs)
删 lastSetMutationBlock 守卫           → RED (test_snapshot_rejects_same_block_mutation)
曲线 floor 17→16 / cap 86→110         → RED (各 2/3 个测试)
epochLength 下限 64→2                 → RED (test_setEpochLength_bounds)

B1 的曲线我逐个锚点独立复算,三个数全对m=17 @β=10% → 2.552e-7(你写 2.6e-7)、m=16 → 1.025e-6(你写 1.02e-6「刚超」,确实刚超)、m=86 @β=1/3 → 9.771e-7(你写的 1e-6 交点)。精确二项式全域扫描 n∈[9,20000]β=0.10 最坏 ε = 7.93e-29,比 1e-6 的目标低 23 个数量级;「N≥430 即使 β=1/3 也满足 1e-6」也成立(n=430 → 1.5e-8,渐近 9.771e-7,只剩 2.3% 余量但没破)。这条曲线是有原理的,比我上一轮开的「把全集带抬到 n≤150」强得多 —— 我那个是没有目标值的创可贴,你这个有交点、有推导。

committeeActive() 也比我提的方案好:enrollment 是对「账户框错了」这个结果做过滤,committeeActive() 让账户直接读驱动 validator 自己解析的那个状态,从源头消掉了 shape 猜测,我的方案会让账户继续猜。


🔴 Blocking

B4 :413 vs :438 —— enrollment 闸门只看 accountId 的低 160 位,而 sortition draw 吃全部 256 位;高 96 位是免费离线可 grind

:413  if (!enrolledAccount[address(uint160(uint256(accountId)))]) return 1;   // 截断到 160 位
:438  uint256 draw = uint256(keccak256(abi.encode(CMT_DOMAIN, seed, accountId, nid)));  // 全 256 位

实测(一次性探针,跑完即删,git status 干净):100 个 bootstrap 节点(m=20、required=14、p=0.2),ACCOUNT 已 enroll,epoch 1/2 已 pin,攻击者持有 100 个里的 33 个(β=1/3):

用规范 accountId:      攻击者被选中 6 个(需要 14)  → validate() = 1   ✅ 正确拒绝
只 grind 高 96 位(低 160 位保持 = ACCOUNT):
                       第 232 次命中
                       低 160 位仍映射到 0x…0A11cE,enrolledAccount[ACCOUNT] == true  → 闸门照过
用 ground accountId:   【同一批 14 个攻击者节点】→ validate() = 0   ❌

所以 :136-137 那句话是假的

enrollment「turns a FREE offline grind over 2^256 accountIds into a gas-metered grind over addresses the attacker had to enroll」

实测:仍然是免费的离线 grind,232 次

我认真掂量了严重性,然后仍然判它 Blocking,理由很窄。破坏主防线 —— 账户注入 address(this) 时高 96 位恒为 0,提交者碰不到;而且在你声明的 β≤10% 下攻击者根本凑不齐 required,grind 一无所获。但这句假声明正是把 B2 从 Blocking 降级成 defense-in-depth 的依据。 要么把闸门收紧,要么把这句话删掉、B2 回到「阻塞在 airaccount 那个 PR 上」。唯一不能上线的就是中间状态:一个看起来关掉了 B2、而 draw 却在读 96 个它从没看过的比特的闸门。

三条反驳我都构造了并逐条否掉:(a) 有没有别处拦了非规范 accountId? accountIdsrc/ 里正好三处(:409 读、:413 截断、:438 全宽 draw),没有第四个消费者。(b) 高 96 位有没有既定语义(chain id / 版本 / 命名空间)? 全仓 contracts/srccontracts/test、TS 源码、docs 里 >> 160<< 160uint96 零命中;而同仓的 OverIssueFraudProofVerifier 对它类似的字段是显式强制规范形式的 —— 这是支持修的仓内先例。(c) 前提太罕见? 那个前提正是这道闸门存在的唯一理由。

(i) 不变量validate() 接受的每个 accountId 必须是它所校验的那个 160 位地址的规范零扩展 —— 查 enrollment 用的值和喂进 draw 的值必须是同一个值
(ii) 路径:committee 路径(:409 → :413 → :438);_validateWholeSet 不读 accountId,空满足;将来任何新的 accountId 消费者都要加进这里。
(iii) 每条路径一个测试:(a) 高 96 位有任意一位置位 → 即便低 160 位是已 enroll 的地址也必须返回 1;(b) 规范 accountId 仍返回 0(防修过头);(c) 对高 96 位做 ≥10⁴ 次有界 grind,断言无一被接受(我的探针 232 次就命中)。
(iv) 错误修法的反向危害掩码而不是拒绝。把 uint256(accountId) & type(uint160).max 喂进 draw 会让 2^96 个不同的线上值验证结果相同 —— grind 的收益没了,但检测畸形 payload 的能力也没了,而且它会改变所有已规范 accountId 的委员会,打断任何已经算好委员会的离线聚合器。要拒绝,不要掩码。 一行 if (uint256(accountId) >> 160 != 0) return 1; 正好对称于你自己在 11 行之下 :424 已经接受的那个 canonical-slot 检查。

B5 :155-156 + :333-334,:348 —— B1 的修法是单尾的:required 取自期望委员会,而实际委员会是 Binom(n, m/n);oversample 降到 1.0 后,6.3% 的 (账户, epoch) 根本不存在可满足的签名者集合

required = ⌈2·m_e/3⌉ 是均值的确定性函数,实际抽出的委员会是随机变量。oversample=1.0 让实际均值恰好等于 m,于是「抽出来不足 required」的概率非常大。全诚实、100% 在线下的精确二项式:

                    本轮(o=1.0)     上一轮(o=1.15)     倍数      节点 95% 在线(o=1.0)
n=85  (m=17,req=12)  6.268e-02       1.120e-02        5.6×        9.527e-02
n=100 (m=20,req=14)  4.691e-02       8.873e-03        5.3×        7.591e-02
n=300 (m=60,req=40)  9.849e-04       8.836e-06       111×         3.711e-03
n=430 (m=86,req=58)  1.637e-04       2.338e-07       700×         9.935e-04
n=20000              5.589e-04       3.079e-06       181×         2.440e-03

坏区不是尾巴上的猎奇,正是现实运行区间n∈[25,175] 全部 >1e-2,n∈[20,310] 全部 >1e-3。用户可见的症状是:约 6.3% 的账户每个 epoch 会遇到一次「压根不存在合法签名者集合」的硬停摆,持续整个 epoch(≥64 区块),而且没有任何它能据以行动的报错。

没有兜底路径:全集退化在 n>17 之后不可达;epochLength==0 是 owner 的总闸不是运行时兜底;换 validator 在账户侧。逐 epoch 重试确实会稀释(seed 每 epoch 变,独立重抽,连续两次 3.9e-3、三次 2.5e-4),但那是「用户随机被停一个 epoch」,不是没有代价。

:151-154 那段推理本身是对的 —— oversample>1 确实会破坏 β=1/3 的 N≥430 保证(我实测:o=1.15 时 m=86 的 target 变 99,λ=33,ε 从 9.77e-7 变成 5.21e-5,超标 53 倍)。错的不是这段推理,是它只优化了一条尾。

而且「两条尾不可兼得」是错的 —— 这一条我先信了,然后被 R4 用联合扫描推翻,我自己复算确认。 我最初的算法固定 oversample=1.0 只扫 m,得出「required≤100 ⇒ m≤150,活性尾最好也只有 5.93e-6」。但 oversample 是第二个独立旋钮(它移动实际均值而不动 required)。(m, o) 联合扫描 n∈[9,20000]

出厂(floor 17 / cap 86,  o=1.00)  活性最坏 6.268e-02  伪造最坏(β=1/3) 9.493e-03   ❌
提案A(floor 100/cap 100, o=1.15)  活性最坏 4.562e-07  伪造最坏(β=1/3) 1.635e-05   ❌(只满足 β≤10%)
提案B(floor 100/cap 149, o=1.10)  活性最坏 3.474e-07  伪造最坏(β=1/3) 1.302e-07   ✅ 两条都过

所以这是一个两参数重新推导,不是重新设计。 提案 B 的代价要说清楚:required = 100 = MAX_NODE_COUNT 正好顶格,k 必须恰好 100,每 op 约 51.5KB calldata。提案 A 便宜得多(required=67,约 34.5KB)且满足你声明的 β≤10% 主目标,但放弃 β=1/3 的赠品。这是个取舍,请显式选一个并写进 PR。

(i) 不变量:对每个将要运行的 n,P(实际委员会 < required) 必须被压到与伪造尾同一个 ε 目标(1e-6)—— required 要对实际分布的下尾安全,而不只是等于均值的 2/3。
(ii) 路径:四个参数共同决定这条尾,四个都得一起定 —— 构造函数默认 oversampleNum/Den:155-156)、曲线 floor(:333)、cap(:334)、以及 setOversample 的可接受区间(:191-198,它不能放进违反该不变量的组合)。
(iii) 每条路径一个测试:给 test_B1_curve_matches_DSR_table 加一列活性尾 —— 现在那张表只断言 (N, m_e, requiredQuorum),是纯伪造侧的,这正是一个单尾改动能通过 110 个绿测试的结构原因;再加一个钉死构造函数默认对的测试(见下,这个变异现在是活的);再加一个 setOversample 的边界测试。
(iv) 错误修法的反向危害required 降到 ⌈2m/3⌉ 以下去吸收缺口 —— 那是把活性尾一比一换成伪造尾,正是你自己在 :47 写的「Lowering quorum on churn would break safety, so it is intentionally NOT done」,会把 6.3% 的不便变成一条伪造路径。唯一正确的杠杆是抬高实际均值(oversample)和/或 m,绝不是降 quorum。 第二个错法:上线后用 setOversample —— 那会触发下面 F2 的 configVersion bump 把 epoch e-1 永久搁浅,用一次保证停摆去买这个修复。这个值必须在构造函数里就是对的。


已确认(follow-up,不阻塞)

  • [High] :277 —— lastSetMutationBlock 守卫在迁移边界上是【零资本】DoS,而那正是本 PR 自己的 interlock 要求走的那道门(Codex 的写入者枚举带出来的,R4 收尾)
    这条守卫是我上一轮开的处方,我得认。它的成本我先前算错了 —— 以为要预置 63 个质押 operator。实际看基类 :927
    bool stale = isBootstrap[nodeId] ? requireStake : !_isStaked(op);
    owner 一旦把 requireStake 翻成 true —— 也就是 :171-176 那段 interlock 明写要走的迁移边界 —— 全部 bootstrap 节点【同时】变成任何人都能 syncNode 的,零质押、只花 gas。 epochLength=64 时 pin 窗口是 63 个区块,任何在迁移时持有 ≥63 个 bootstrap 节点的网络,都等于把「每个区块一次免费驱逐」递给了攻击者;而一个没 pin 上的 epoch e 会让 validate() 在 e(无 seed)和 e+1(无 setRoot)两个 epoch 全网失效
    更要命的是它不需要攻击者 —— 迁移期间一个老实 keeper 跑例行 syncNode 清理,就会和 snapshotEpoch 撞车。
    而且这道守卫并没有完全关掉它要关的东西:在区块 B-1 驱逐、在 B 冻结,依然成立;它把一次原子组合换成了一场单区块竞速,代价是卖出一根 63 区块的 DoS 杠杆。这笔买卖是亏的,是我开的。
    更好的修法:不要 revert,改成闩住上一区块的状态 —— hook 里 if (block.number != lastSetMutationBlock) { rootAtBlockStart = runningRoot; countAtBlockStart = activeCount; lastSetMutationBlock = block.number; }snapshotEpoch() 在本区块被改动过时 pin 那份上一区块的值。原子「驱逐后冻结」变得不可能,同时 snapshotEpoch() 变成无条件可调,撞车问题一并消失。
  • [Medium] :196 —— setOversampleconfigVersion bump 用「砖化」关掉了追溯性,代价没人算过
    e-1 永远补 pin 不回来(它的 256 区块窗口早已关闭),而 validate() 需要 _epochUsable(e-1)。所以任何一次 setOversample/setEpochLength 都会让 committee 模式停摆到 epoch e 结束;当 epochLength >= 258 且调用落在 startBlock+256 之后时,e+1 整个也停
    更好的修法:把 epochOversampleNum/Den 和 seed/root/count 一起冻进快照validate() 读被 pin 的那一对 —— 追溯性照样关掉,零停摆,而且 B3 这条性质变得可测而不需要一次全局作废。
  • [Medium] :309 unenroll() —— 单向陷门enroll()msg.sender 为键,所以重新 enroll 需要账户去执行一笔交易,而那需要 validate() 过,而那需要已 enroll。对只挂了这一个 validator 且 epochLength != 0 的账户,unenroll() 是一次性自砖。natspec 那句「e.g. before migrating away」恰好指导了砖化的顺序(先 unenroll、后卸载)。只能靠 owner 关掉 committee 模式或第二个 validator 救。最低限度先把那句提示删掉。
  • [Low] :296/:356 —— committeeActive() 只看 epochLength != 0requiredQuorum() 只看 e-1,而 validate() 需要 ee-1 两个。既然按 :293-295 账户是用 committeeActive() 选择线格式的,那么在任何一个失效 epoch(包括上面那段停摆)里,账户会按 committee 方式打包、requiredQuorum() 返回一个看起来可满足的有限数,而每一笔 op 都 fail-closed 且没有回退。
  • [Low] :180 —— β≤10% / N≥N0 这条假设没有任何链上闸门setEpochLength 从不读 activeCount
  • [Low] carried —— registerWithProof_onNodeActivated 仍然零覆盖(变异存活 110/110,R4 独立重跑同样结果)。它是 requireStake=true 之后唯一的注册路径,而它只被基类套件测过,从没对 committee 子类测过。上一轮我点了三条,你关了两条,这条还开着。
  • [Low] 两个新的存活变异:把构造函数 oversample 1/1 改回 115/100110/110 全绿(整个 B1 论证赖以成立的那个值没有测试钉住);把 requiredQuorum() 的哨兵从 type(uint256).max 改成 0110/110 全绿(我上一轮明写过 0 是 fail-open 的反向危害,修好了但没钉住)。

驳回

  • R1a-2(enrollment 检查排在 _thresholdOf 之后浪费 gas)—— validate()view,几百 gas 在 14 层 Merkle 折叠和 pairing 面前是噪音。
  • R1a-3 / R1b-1 —— 与 B4 同一条,不是独立发现。
  • R1b-2(permissionless enroll 可以刷地址)—— 以 msg.sender 为键、攻击者自费、对第三方无影响;威胁模型 :138 本来就承认攻击者能 enroll 自己的账户。
  • Codex 的 MISSED_NEW「_onNodeDeactivatedactiveCount 减了两次」—— 错的,有据grep 显示全文件恰好一处 activeCount -= 1:230)、一处 += 1:218),且 :226if (sp == 0) return; 让 hook 即使被重复调用也幂等。
  • 我自己「两条尾不可兼得」的推论 —— 见 B5,被联合 (m, o) 扫描推翻,我复算确认。这条如果我不纠正,会把一个两参数的修法说成需要重新设计。

建议

  • 两条 Blocking 的性质不同,别放一起排期:B4 是一行 + 三个测试,改完那句 natspec 就是真的;B5 是一次两参数重新推导(提案 A 便宜、只保 β≤10%;提案 B 两条尾都过但 required 顶格 100、calldata ~51.5KB)—— 请显式选一个并把取舍写进 PR,不要让它留在注释里当默认值。
  • test_B1_curve_matches_DSR_table 加一列活性尾。 现在那张表只有 (N, m_e, requiredQuorum),纯伪造侧 —— 这就是一个单尾参数改动能通过 110 个绿测试的结构原因。每行加一个下尾数字,B5 在 review 之前就会是红的。
  • lastSetMutationBlockconfigVersion 两处是同一个反模式:让快照的成立依赖于「世界静止」。两处的解法也是同一个 —— 把值冻进快照(pin 上一区块的 root/count;pin 当时的 oversample 对)。两处都不需要 revert,也都不需要全局作废。

各轮结论

轮次 模型 结果
R1a deepseek-v4-flash 3 条全在 :410-413#1(160 位截断)是真的,直接成了 B4 —— 这是它这轮的实打实命中;#2(gas 排序)驳回,#3#1 的复述
R1b deepseek-v4-flash(安全) 复述同一条并降了级;「permissionless enroll」驳回。相对 R1a 的净增量为零
R2 Opus 独立评审 本轮的引擎:独立挖出 B5、把 lastSetMutationBlock 的每一个写入者逐个定价、并正确指出「三条处方里有两条产出的代码比它关掉的问题更糟」。唯一过头的一条(两条尾不可兼得)被 R4 推翻
R3 Codex PK(真跑 codex exec,gpt-5.6-sol,self-check 双 grep 命中) 5/5 CONFIRM、零成功挑战,作为对抗轮偏弱;但它独立走了一遍 validate()(「no fallback while enabled」)佐证了 B5,而它枚举 syncNode 写入者这一步,正是后来查到 isBootstrap ? requireStake 那个零资本迁移窗口的线索。它唯一的 MISSED_NEW 是假的,已用 grep 驳回
R4 Opus 裁决 REQUEST_CHANGES。证伪 B5 的五条反驳里四条失败、第五条(两条尾不可兼得)成功推翻并给出可行参数;独立重跑两个变异(结果与我一致);新挖出 syncNode 在迁移边界的零资本 DoS

机械证据:worktree HEAD 6f8daa4dgh pr view 核对一致;forge test 110/110;11 个变异(字面量替换 + assert 单点命中,逐个 git checkout -- 还原,末尾 git status 干净)8 红 3 存活;高 96 位 grind 探针实跑(规范 accountId 命中 6/14 → rc=1;grind 232 次 → 同一批 14 个节点 rc=0;低 160 位仍映射到已 enroll 地址);曲线三个锚点 Poisson 复算(2.552e-7 / 1.025e-6 / 9.771e-7);伪造尾与活性尾精确二项式全域扫描 n∈[9,20000](β=0.10 最坏 7.93e-29;β=1/3 最坏 9.49e-3 @n=90;活性最坏 6.268e-2 @n=85);(m, o) 联合扫描三组参数;syncNodeisBootstrap ? requireStake 判据与 accountId 的三个消费者逐行核;>> 160/<< 160/uint96 全仓零命中。

PR-Daemon v4 · 4-round PK pipeline · 结论由 Opus 裁定

… zero)

pr-daemon round-2 B4: the enrollment gate reads accountId's low 160 bits but
the sortition draw consumes all 256, so the high 96 bits were a free offline
grind surface (probe: 232 tries to find a high-bit value whose low 160 still
map to an enrolled address, flipping the same attacker committee from reject to
accept). Fix: reject any accountId with non-zero high bits (symmetric to the
canonical-slot check), and correct the false 'gas-metered grind' natspec claim.

Tests: non-canonical accountId rejected, 300-iteration high-bit grind all
rejected, requiredQuorum sentinel pinned to max (not fail-open 0). 36 committee
tests pass. NOT pushed — held with B5 (needs an architectural decision).
…ound-2 follow-ups

B5 (Blocking): B1's floor-17/oversample-1.0 curve was single-tailed — ~6.3% of
(account,epoch) had NO satisfiable signer set (honest-liveness stall). Jason's
call: a liveness miss is a retryable one-epoch stall (not fund loss), so hold it
to a LOOSER target than forgery. Verified two-tail operating point (exact
Binom/Poisson scan over n in [9,20000]):
  m_e(N) = clamp(ceil(N/5), 30, 86), oversample = 1.25
  forgery @β<=10% <= ~4.4e-9 (sampling regime)  [target 1e-6]
  liveness <= 1.65e-4 (100% online) / 5.9e-4 (95% online)  [loose target 1e-3]
floor 30 (up from 17) buys the forgery headroom to afford oversample 1.25;
floor 17 could not (its forgery tail was 4.6e-7, razor thin). Gives up the old
β=1/3 'free gift' (out of scope under β<=10%). Cost: requiredQuorum 20..58,
~10KB (N<=150) .. ~29KB (N>=430) calldata/op — far below the strict-liveness
34-51KB. Added a two-tail table test (asserts E[committee] clears quorum) and a
constructor-oversample pin.

round-2 follow-ups:
- High: lastSetMutationBlock revert was a zero-capital migration-boundary DoS
  (setRequireStake(true) makes every bootstrap node permissionlessly
  syncNode-able; a forced same-block collision could block pinning a whole
  epoch). Replaced with a block-start LATCH: snapshotEpoch freezes the
  pre-mutation root/count when the set changed in its own block, and is now
  UNCONDITIONAL (no revert). Discriminating test added (verified via mutation).
- Medium: unenroll() natspec no longer suggests the self-bricking order; warns
  instead.

B4 (prior commit ea1e6a7): reject non-canonical accountId.

115 tests pass.
@jhfnetboy
jhfnetboy requested a review from clestons August 17, 2026 07:29
clestons
clestons previously approved these changes Aug 17, 2026

@clestons clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ APPROVE — 817bf360 增量复审(第 3 轮)[4-round]

两条 Blocking 都真关掉了,而且 B5 你没有硬凑一个「两条尾都到 1e-6」的参数,而是把活性目标显式降到 1e-3 并写清楚了理由 —— 那比凑出来的数诚实。


✅ B4 —— 一行修 + 一个 grind 测试(实测)

if (uint256(accountId) >> 160 != 0) return 1;     // 放在 enrollment 闸门【之前】

:136-137 那句被我实测证伪的「gas-metered grind」也删了,换成了准确的表述(闸门读的低 160 位 = draw 消费的全部 256 位)。

删掉这一行 → RED (test_validate_rejects_noncanonical_accountId, test_validate_highbits_grind_all_rejected)

你自己加了 grind 测试 —— 那正是我上一轮 232 次命中的那个探针的钉子版本。

✅ B5 —— 两条尾的每个数我都独立复算,全对

floor 17→30oversample 1.0→1.25 (5/4),活性目标 1e-3(Jason 拍板)。精确 Binom/Poisson 全域扫 n∈[9,20000]

                      活性最坏            伪造最坏(β=10%)      required   抽样起点
本轮 floor30 o=1.25   1.654e-04 @n=160    5.29e-24(精确)       [20,58]    n=39
  95% 在线            5.899e-04 @n=160
上轮 floor17 o=1.00   6.268e-02 @n=85     7.93e-29             [12,58]    n=18

你写的 ≤1.65e-4 / ≤5.9e-4 / required∈[20,58] / N0≈39 —— 四个数逐个对上,一个不差。

而且我核了「为什么必须先抬 floor 才能吃下 oversample」这个选择本身是对的:

floor17 + o=1.25 → 活性 2.913e-03  ❌ 破 1e-3
floor30 + o=1.00 → 活性 1.559e-02  ❌ 破 1e-3
floor30 + o=1.25 → 活性 1.654e-04  ✅  两条尾同时满足

两个旋钮必须一起动,单动任一个都不行 —— 你选的点是这个约束下的可行解。

lastSetMutationBlock —— 我上一轮承认开错的那条处方,你按更好的形状改了

_latchBlockStart() 在每个 block 的第一次变更时闩住变更前的 root/count,snapshotEpoch() 变回无条件(不再 revert)。这同时关掉了原子「驱逐后冻结」和我造出来的那个迁移边界零资本 DoS。逐行核过:闩在两个 hook 的早退之后、状态变更之前,多次变更只闩第一次,snapshot 在变更之前跑时读到的活值本来就是块起始值 —— 四种时序都自洽。

让 snapshot 忽略闩(直接用 runningRoot/activeCount) → RED (test_snapshot_freezes_block_start_state)

其余上一轮点的也都关了:unenroll() 的 natspec 从「指导砖化顺序」改成了警告;requiredQuorum 哨兵、构造函数 oversample 两个上轮存活的变异现在都有测试钉住。

forge test = 115 passed / 0 failed (上轮 110)
12 个变异 10 红:look-ahead · _deactivate hook · enroll 检查 · setOversample bump ·
                 曲线 floor · 曲线 cap · oversample 默认值 · requiredQuorum 哨兵 ·
                 canonical accountId · snapshot 忽略闩

已确认(follow-up,不阻塞合并)

[Medium] _latchBlockStart 的「同一 block 多次变更」没有覆盖 —— 而那正是现实的 griefing 形状(批量驱逐)

变异: `if (block.number != lastSetMutationBlock)` → `if (true)`   → 115/115 全绿 ⚠️

if (true) 会让每一次变更都覆盖闩,于是 rootAtBlockStart 变成「最后一次变更之前」而不是「块起始」——k 次变更就漏掉 k-1 次的压低。test_snapshot_freezes_block_start_state 只做了一次变更(registerPublicKey ×1),所以它区分不了这两者。

我写了 12 行探针(同一 block 内 revokePublicKey ×2 再 pin),当前代码 PASS(pin 到 5 = 块起始真值),M18 下 FAIL(pin 到 4)

--- 当前代码 ---  pinned epochSetCount: 5   [PASS]
--- M18     ---  pinned epochSetCount: 4   [FAIL: 4 != 5]

:把 test_snapshot_freezes_block_start_state 的单次变更改成 ≥2 次,其余不动。代码是对的,缺的只是这一个断言。

反向危害:如果改成在 snapshotEpoch 里额外校验「本块变更次数」,就又把 snapshot 变成有条件的,等于把上一轮那个 DoS 请回来。只加断言,别动实现。

[Low] 文档里两处数字与实测不符(结论不受影响,但这条线上的数字已经被引用了三轮)

  1. : 曲线注释「a liveness miss is a retryable one-epoch stall (seed re-draws each epoch: two-in-a-row ~5.9e-7)」——
    实测:最坏单次 100% 在线 1.654e-4 → 连续两次 2.74e-8;95% 在线 5.899e-4 → 连续两次 3.48e-7
    5.9e-7 看着像是把 5.9e-4 的尾数直接换了指数。建议写 ~3.5e-7(95% 在线) 并注明是哪个在线率。
  2. 同段「floor 17 could NOT afford any oversample(its forgery tail was 4.6e-7, razor thin)」——
    4.6e-7 这个 Poisson 界我复算得到同值,但它 ≤1e-6,也就是说按伪造尾 floor 17 其实是「吃得下」的(只是余量只剩 2 倍)。真正卡住 floor 17 的是活性尾floor17 + o=1.25 → 2.913e-3,破 1e-3 目标 3 倍。
    参数选对了,写下来的理由是两条约束里不 binding 的那一条。 这类「安全性论证写错了绑定条件」正是我在 #235 拦过的同一类问题(那次是 :1046 的假声明),建议改成活性尾。

[Low] β=1/3 的退化幅度值得写进 PR,而不只是一句「out of scope」
注释说放弃 β=1/3 这个「free gift」是对的、也是被拍板过的。但数字应该在场:oversample 1.25 会抬高 λ,所以 β=1/3 下的伪造尾从上一轮的 9.49e-3(@n=90) 变成 1.61e-2(@n=150) —— 是变差了,不只是「不再保证」。 在 β≤10% 的假设下无碍(精确 5.29e-24 / Poisson 界 ~4.4e-9),但如果哪天要重新讨论 β 假设,这个数是起点。

[Low] 95% 在线那一栏是全设计里余量最薄的数5.899e-4 vs 目标 1e-3,只有 1.7 倍。节点在线率再掉一点(或 MAX_NODE_COUNT 那侧再收紧)就会破。建议把在线率当成一个显式参数写进那张表,而不是脚注。

[Low] carried(第三轮仍在)—— registerWithProof_onNodeActivated 零覆盖

删掉它 → 115/115 全绿 ⚠️(第三轮同样结果)

它是 requireStake=true 之后唯一的注册路径,而只被基类套件测过,从没对 committee 子类测过。一个 MockCommitteeValidator 上走 registerWithProof 再断言 activeCount/slotPlusOne/runningRoot/SlotAssigned 都动了,就关掉了。

[Low] carried —— 上一轮已认可的三条setOversampleconfigVersion bump 仍会造成至少一个 epoch 的停摆(更好的形状是把 oversample 对冻进快照);committeeActive() 只看 epochLength != 0requiredQuorum() 只看 e-1,而 validate() 需要 ee-1 两个;β≤10% / N≥N0 仍无链上闸门(setEpochLength 不读 activeCount)。


建议

  • 合并前只有一件事值得做:把 test_snapshot_freezes_block_start_state 的变更次数从 1 改成 2。一行改动,关掉本轮唯一一个落在新代码上的存活变异。
  • 两处数字(5.9e-7floor 17 卡在哪条尾)和 β=1/3 的退化幅度一起改,是同一个提交。
  • registerWithProof 那条已经挂了三轮了,建议这次一并收掉 —— 它是生产注册路径。

各轮结论

轮次 模型 结果
R1a/R1b deepseek-v4-flash 未跑 —— 增量 = 修我自己上一轮的 findings(ABSOLUTE CONSTRAINT #5 豁免②)。两个 commit 全部是 B4/B5 + round-2 follow-up,基类零改动
核验 本轮主体 精确 Binom/Poisson 全域扫 n∈[9,20000] 复算作者四个声称值;(floor, oversample) 三组对照证明两个旋钮必须一起动;12 个变异 10 红;两个自写探针(多次变更闩、canonical accountId)
裁决 Sonnet APPROVE。两条 Blocking 关闭,无新增 High/Medium 缺陷;剩余全是覆盖与文档

机械证据:worktree HEAD 817bf360gh pr view 核对一致;forge test 115/115;12 变异(字面量替换 + assert 单点命中,逐个 git checkout -- 还原,末尾 git status 干净)10 红 2 存活;活性尾/伪造尾精确扫描(1.654e-4@n=160、5.899e-4@95% 在线、required∈[20,58]、抽样起点 n=39、β=10% 精确 5.29e-24、β=1/3 最坏 1.608e-2@n=150);(floor17,o1.0)/(floor17,o1.25)/(floor30,o1.0)/(floor30,o1.25) 四点对照;连续两次失败概率复算(2.74e-8 / 3.48e-7);_latchBlockStart 四种时序逐行核 + 双变更探针双向验证(当前 PASS pin=5,M18 FAIL pin=4)。

PR-Daemon v4 · 轮数如实标注(R1 按豁免②未跑)

The Smart Contracts CI job runs `forge coverage`, which disables via-IR (the
optimizer that makes the frame fit) — so validate()'s large frame failed to
compile there (Stack too deep), even though forge build/test (via_ir=true)
compiled fine. Refactor to stay within the non-via-IR stack limit:
- extract the per-signer membership+sortition loop into _verifyCommitteeSigners
- pack its params into a memory _Ctx struct (one stack word, not four)
- block-scope the parse/quorum intermediates in validate()
No behavior change: 115 tests pass; verified the exact coverage compile
condition (FOUNDRY_VIA_IR=false FOUNDRY_OPTIMIZER=false) now succeeds with zero
stack-too-deep. (Security Audit CI failure is pre-existing npm-dep advisories,
unrelated to contracts.)

@clestons clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ REQUEST_CHANGES — 59cf455 增量复审(第 4 轮)[2-round]

这个 commit 声称是纯栈布局重构("resolve forge-coverage stack-too-deep"),但它改变了行为:把 bytes32 accountId = bytes32(signature[0:32]) 挪到了长度守卫之前,于是短签名从「返回 1」变成了「revert」。115/115 全绿,因为零测试改动、也没有测试覆盖这一路。

顺带查出第二件事,而且是我上一轮该抓没抓的:sortition draw —— 整个 CC-98 设计的核心机制 —— 今天零绑定覆盖


🔴 Blocking

B6 AAStarCommitteeValidator.sol:~420 —— 重构把 signature[0:32] 提到了长度检查之前,短签名现在 revert 而不是返回 1

重构前后的顺序:

817bf360 (上一个 head)                    59cf455 (本增量)
  ...                                       ...
  if (signature.length < 32+G2_LEN)         bytes32 accountId = bytes32(signature[0:32]);   ← 切片,还没查长度
      return 1;                             if (uint256(accountId) >> 160 != 0) return 1;
  ...解析/quorum...                          if (!enrolledAccount[...]) return 1;
  bytes32 accountId = bytes32(sig[0:32]);   { if (signature.length < 32+G2_LEN) return 1;  ← 长度检查挪到了后面
                                              ...解析/quorum... }

signature.length < 32signature[0:32] 这个 calldata 切片会 revert前后对照实测(同一个探针、同一份测试文件,只换 AAStarCommitteeValidator.sol):

输入                    817bf360(我上轮 APPROVE 的)      59cf455(本增量)
4 字节 signature         call succeeded: yes, returned 1   ❌ NO - IT REVERTED
31 字节 signature        call succeeded: yes               ❌ NO - IT REVERTED

这违反的是本合约自己写下的性质(基类 validate 的注释):

It is uniformly fail-closed: EVERY malformed / unregistered / retired-bootstrap / non-quorum input returns 1 rather than reverting (the account calls it under try/catch, treating a revert as fail; returning 1 keeps the failure signal uniform).

而且这正是姊妹仓 airaccount-contract#202F-W11 存在的理由 —— 那一条的全部意义就是「不要在 validation 阶段 revert(ERC-7562)」。同一个规则,一个仓在加固它,另一个仓在同一天把它弄丢了。

前提可达性:epochLength != 0 且 epoch 已 pin 时就会走到这一路,validate 是 external、签名字节由提交者任意构造,没有任何上游保证 length >= 32

(i) 不变量validate()任何输入都返回 01,永不 revert。
(ii) 必须满足它的路径:committee 路径的每一次 calldata 切片(signature[0:32]、per-signer 的 [off:off+32]/[off+32:off+64]/proof 切片、尾部 signature[length-G2_LEN:]);_validateWholeSet 已经在读之前先查长度,是对的。
(iii) 每条路径一个测试:长度 0 / 4 / 31 / 32 / 32+G2_LEN-1 的签名各断言 validate() == 1(= 我这个探针,直接收编即可);再加一个 body % perSigner != 0 的畸形长度。
(iv) 错误修法的反向危害别用 try/catch 或 assembly 去「兜住」revert —— 那会把一个可以静态排除的输入校验问题换成一层运行时包装,并且掩盖将来新加的切片。也别只在 validate 顶部加一个 signature.length < 32 就算完 —— 真正的修法是把那整块长度/解析守卫移回 accountId 读取之前,恢复「先验长度、再碰字节」的顺序;这样将来任何新增的前置字段都自动被守住。重构本身(把解析和逐签名者校验拆进 block scope 和 _verifyCommitteeSigners)是好的,只是顺序不能换


🔴 High(覆盖,非缺陷 —— 但这条我上一轮该抓没抓)

B7 sortition draw 零绑定覆盖 —— 唯一测它的用例走进了空断言分支

变异: 把 `if (keccak(...) >= ctx.T)` 改成 `>= type(uint256).max`(等于整个抽签失效)
      → 115/115 全绿 ⚠️

根因在 test_validate_rejects_wrong_accountId_committee

bool someExcluded;
for (...) { if (draw >= T) someExcluded = true; }
if (someExcluded) {                                   // ← 守卫写成了 if,不是断言
    assertEq(v.validate(keccak256("op"), _payload(other, chosen)), 1, "committee is account-bound");
}

你加这个 sanity 检查的直觉是对的(注释写着「else the test is vacuous」),但它被写成了 if 而不是 assertTrue —— 于是当它为假时,测试静默地什么都不断言。我直接证明了它今天就是假的:

把 `if (someExcluded) {` 前面插一行 assertTrue(someExcluded, "...")
→ [FAIL: PROBE: the sanity guard is FALSE -> the assertion below never runs]

两条独立证据指向同一结论:变异存活 + 守卫可证明为假。所以整个 CC-98 最核心的那个机制,今天没有任何测试钉住它

if (someExcluded) {assertTrue(someExcluded, "..."); 然后无条件断言。如果换成 assertTrue 之后这个 seed/ids 组合确实凑不出 someExcluded,那就调 _registerNodes 的数量或 epoch seed 直到凑出来 —— 不要退回 if

这条是我的漏检,得说清楚。 第 1 轮我跑过「删 sortition draw」这个变异,当时是红的test_validate_rejects_wrong_accountId_committee 抓住了它)。第 2 轮加了 enrollment 闸门之后,这个测试的失败模式变了 —— 现在 otherv.enroll()someExcluded 为假,测试改由别的原因通过。而我第 2、3 轮没有把这个变异重跑一遍,就在第 3 轮 APPROVE 了。
教训:一个变异红过一次,不等于它永远红。 每一轮都要跑完整套,尤其是在有新闸门加进同一个函数之后 —— 新闸门会把老测试的失败原因换掉。


✅ 重构本身是对的(除了那个顺序)

其余部分我逐条核过,行为等价:

尾部切片 signature[signature.length - G2_LEN:]  ≡  原来的 signature[off:off + G2_LEN]
   (off = 32 + k*perSigner,而 body = length - 32 - G2_LEN = k*perSigner ⇒ off = length - G2_LEN)  ✓
_verifyCommitteeSigners 里每个 `return 1` → `return (false, nodeIds)`,调用方先看 ok  ✓
_Ctx 打包成一个 memory 指针,四个值原样传递  ✓
forge test = 115 passed / 0 failed(与上一个 head 同数,零测试改动)

其余守卫的变异仍然精确变红,说明重构没有顺手弄丢别的绑定:

look-ahead setRoot[e-1]→[e]   → RED    删 enrollment 检查        → RED
删 canonical accountId 检查    → RED    删 _verifyMerkle          → RED
删 quorum 闸门                 → RED    曲线 floor 30→17          → RED
constructor oversample 5/4→1/1 → RED

carried(前两轮已点,仍开着)

  • registerWithProof_onNodeActivated 零覆盖 —— 第四轮了。它是 requireStake=true 之后唯一的注册路径,只被基类套件测过。
  • _latchBlockStart 的「同一 block 多次变更」未覆盖 —— if (block.number != lastSetMutationBlock) 改成 if (true) 仍然 115/115 全绿;作者的测试只做一次变更,区分不了「块起始」和「上一次变更之前」。我上一轮给的 12 行探针(同 block revokePublicKey ×2)当前 PASS、变异下 FAIL,直接收编即可。

建议

  • B6 一行顺序改回来就好,然后把我那个长度探针收进套件(0/4/31/32 四个长度)。这条不能带着合 —— 它把一个合约自己写下来的、姊妹仓正在加固的性质弄丢了,而且是在一个自称行为中性的 commit 里。
  • B7 一行 ifassertTrue。这条不阻塞合并(代码是对的),但它意味着这个 PR 最核心的机制目前没有回归保护 —— 在它被钉住之前,任何后续改动都可能悄悄弄坏抽签而全绿。
  • 两条加上 carried 的两条,都是测试侧的一行到十几行,建议一个 commit 收掉。

各轮结论

轮次 模型 结果
R1a/R1b deepseek-v4-flash 未跑 —— 增量是纯重构 + 修我自己上一轮的 findings(ABSOLUTE CONSTRAINT #5 豁免②)。源码 173 行全是把 validate() 拆进 block scope 与 helper,测试零改动
核验 本轮主体 前后对照实测(同探针换源码文件)证明短签名从 return 1revert;8 个变异 7 红 1 存活;用 assertTrue 探针证明 sanity 守卫恒假;切片等价性逐行核
裁决 Sonnet REQUEST_CHANGES。一条行为回归(阻塞)+ 一条核心机制零覆盖(我上一轮的漏检)

机械证据:worktree HEAD 59cf455gh pr view 核对一致;forge test 115/115;短签名前后对照(817bf360 4/31 字节均 returned 159cf455 均 REVERT,同一测试文件只换源码);8 变异(字面量替换 + assert 单点命中,逐个还原,末尾 git status 干净)7 红 1 存活;assertTrue(someExcluded) 探针实测 FAIL,证明空断言分支;尾部切片 off = length - G2_LEN 的等价性逐项推导核对。

PR-Daemon v4 · 轮数如实标注(R1 按豁免②未跑)

…sts (pr-daemon round-3)

The forge-coverage refactor (59cf455) introduced a behavior regression and
exposed a pre-existing coverage hole in the core mechanism:

1. Regression (mine): the refactor moved the accountId calldata slice ahead of
   the length guard, so signature.length < 32 REVERTED instead of returning 1,
   breaking the base contract's uniform fail-closed. Fix: hoist
   `if (signature.length < 32 + G2_LEN) return 1` to the top of the committee
   path, before ANY slice. Regression test: empty / 4-byte / 31-byte sigs all
   return 1 (verified via mutation: reverting the order makes it revert again).

2. Coverage hole (pre-existing, dismissed the false APPROVE): the sortition draw
   had NO regression protection — the account-binding test guarded its only
   assert behind `if (someExcluded)`, which was always false, so deleting the
   entire draw check left 115/115 green. Replaced with:
   - test_validate_sortition_rejects_noncommittee_signer: baseline committee
     validates; swapping one member for a non-committee (but registered +
     Merkle-valid) node must be rejected BY SORTITION. Verified discriminating:
     deleting the draw check flips it 1->0 (RED).
   - test_validate_committee_is_account_bound: same members rejected for a
     different account; asserts unconditionally (require someExcluded).

Coverage still compiles (0 stack-too-deep). 117 tests pass.
@jhfnetboy
jhfnetboy requested a review from clestons August 17, 2026 09:00

@clestons clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ APPROVE — 12cde69 增量复审(第 5 轮)[2-round]

两条都关了,而且都是用发现它们的那把工具验的。


✅ B6 —— 长度守卫提到了第一行,短签名恢复 return 1

if (epochLength == 0) return _validateWholeSet(hash, signature);
if (signature.length < 32 + G2_LEN) return 1;   // ← 在任何 calldata 切片之前

我用上一轮那个前后对照探针原样重跑(就是抓到它的那个):

              59cf455(上一轮)        12cde69(本轮)
4 字节签名     NO - IT REVERTED  →   call succeeded: yes, returned 1  ✅
31 字节签名    NO - IT REVERTED  →   call succeeded: yes              ✅

而且你自己加了回归测试(hex"" / 4 字节 / 31 字节三个长度),删掉那行守卫 → REDtest_validate_short_signature_fails_closed)。这条以后不会再悄悄回来。

✅ B7 —— sortition 从零覆盖变成两个测试钉住

if (someExcluded) 换成了 setup 里的 require(someExcluded, "setup: chosen must not be a full committee for other too"),并另加了一个 test_validate_sortition_rejects_noncommittee_signer

变异「整个抽签失效」→ RED,被两个测试同时抓住:
   test_validate_committee_is_account_bound()
   test_validate_sortition_rejects_noncommittee_signer()

换成 require 而不是 assertTrue 是更好的选择 —— 它把「前提不成立」标成 setup 失败而不是断言失败,读日志的人一眼能分清「机制坏了」和「用例没构造出来」。


本轮跑的是完整变异套件(16 个),不是只跑新增守卫

上一轮我在自评里认过:第 1 轮红过的抽签变异,我第 2、3 轮没重跑,结果第 3 轮 APPROVE 早了。这次按那条教训整套重跑:

14 红:look-ahead · _deactivate hook · enroll 检查 · setOversample bump · 曲线 floor · 曲线 cap ·
       oversample 默认值 · requiredQuorum 哨兵 · canonical accountId · snapshot 忽略闩 ·
       _verifyMerkle · 【抽签】 · quorum 闸门 · 【长度守卫】
forge test = 117 passed / 0 failed (上轮 115)

carried(仍开着,都是覆盖不是缺陷)

  • registerWithProof_onNodeActivated 零覆盖 —— 第五轮了requireStake=true 之后它是唯一的注册路径,却只被基类套件测过,committee 子类上一次都没跑过。一个 MockCommitteeValidator 上走 registerWithProof 再断言 activeCount/slotPlusOne/runningRoot/SlotAssigned 都动了,就关掉了。
  • _latchBlockStart 的「同一 block 多次变更」未覆盖 —— 第三轮。if (block.number != lastSetMutationBlock) 改成 if (true) 仍然 117/117 全绿;作者的测试只做一次变更,区分不了「块起始」和「上一次变更之前」。我第 3 轮给过 12 行探针(同 block revokePublicKey ×2,当前代码 pin=5、变异下 pin=4),直接收编即可。批量驱逐正是这个守卫要防的现实形状,所以这条比它看起来重要。

两条合起来是一个提交的量。


各轮结论

轮次 模型 结果
R1a/R1b deepseek-v4-flash 未跑 —— 增量 = 修我上一轮的两条 findings(ABSOLUTE CONSTRAINT #5 豁免②)。源码 26 行(挪一行守卫),测试 +155 行
核验 本轮主体 前后对照探针原样重跑(B6 的发现工具);完整 16 个变异重跑;新测试逐个读
裁决 Sonnet APPROVE。两条 Blocking 关闭,无新增缺陷,剩余两条为跨轮 carried 覆盖项

机械证据:worktree HEAD 12cde69gh pr view 核对一致;forge test 117/117;短签名前后对照探针重跑(4/31 字节均 returned 1,与 59cf455 的 REVERT 对照);完整 16 变异(字面量替换 + assert 单点命中,逐个 git checkout -- 还原,末尾 git status 干净)14 红 2 存活;新增三个测试逐行读并各自用变异验证其绑定。

PR-Daemon v4 · 轮数如实标注(R1 按豁免②未跑)

@jhfnetboy
jhfnetboy merged commit 6a929d4 into master Aug 17, 2026
8 of 10 checks passed
@jhfnetboy
jhfnetboy deleted the feat/cc98-committee-validator branch August 17, 2026 09:18
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants