feat(CC-98): production per-proposal committee BLS validator - #237
Conversation
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).
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 7d91f963 首审 [4-round]
实现是对的,方向也是对的 —— 挡下这个 PR 的不是 bug,是两条它自己写下来但没有兑现的安全声明。
#235 那条「committee 无上界但 quorum 有」的不可满足死锁,这一版真的关掉了:m ≤ 110 ⇒ requiredQuorum ≤ ⌈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 ≤ 110 把 target 钉死在 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 + 32s ⇒ body = 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/5 时 target = ⌈over·n/5⌉ >= n 对任何 ≥5 的 oversample 比值成立,覆盖整个 80 ≤ n ≤ 550;over=8 时一直到 n=880。n=2000 时同样的塌缩会把确定性下限放到 74/2000 = 3.7%。
不对称本身就是判据:setEpochLength 会 configVersion += 1,正是为了让重配置不能套用到旧 pin 上;而 setOversample 改的是委员会定义的另一半,不 bump。
(i) 不变量:一个 epoch 一旦 pin,其委员会定义即不可变;且
setOversample接受的任何值都不得把 sortition 门塌缩成全集。
(ii) 路径:_thresholdOf的target >= n分支;validate()对oversampleNum/Den的实时读取(用于一个更早参数下 pin 的 epoch);requiredQuorum()同样的实时读取(账户侧镜像检查信的就是它)。
(iii) 每条路径一个测试:断言当⌈num·m/den⌉ >= 当前 epochSetCount时setOversamplerevert(或 bumpconfigVersion);断言setOversample前无效的 payload 不会在同一 epoch 内被变成有效;断言对已 pin 的 epoch,requiredQuorum()跨 oversample 变更保持稳定。
(iv) 错误修法的反向危害:只把界收紧到num <= 2*den看着够用,并不够 —— 它把追溯性原样留着,将来任何一次放宽这个界就会悄悄重新打开同一个洞;而且它仍然允许 owner 在 epoch 中途平移target,让所有在途聚合器的计算作废(一个没有任何报错的活性 DoS)。主修是给setOversample加configVersion += 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 全绿 ⚠️_payload用getMerkleProof()对当前根建证明,所以没有任何测试让冻结树和活树不一致(look-ahead);committee 套件全部用 bootstrap 的registerPublicKey注册、用revokePublicKey注销(后者有自己的直连 hook:1016),于是registerWithProof(requireStake=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 里记lastSetMutationBlock,snapshotEpoch里require(lastSetMutationBlock < block.number),把一个必然成功的原子操纵变成一场诚实 keeper 能赢的竞速。反向危害:在snapshotEpoch里加require(activeCount >= MIN_POOL)会在网络收缩时让 committee 模式彻底签不上 —— 永久砖化,比这个攻击更糟。 - [Medium] 基类
setRequireStakevs 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——configVersionbump 会作废e-1的 pin,而e-1永远补签不回来(只有当前 epoch 可签),所以每一次setEpochLength(含首次启用)之后 committee 模式至少停摆一整个 epoch。这是上面那条「重签规则比处方好」的真实代价:请写进 header 的 LIVENESS 假设,不要去工程掉它。 - [Low]
:397-413——getMerkleProof只出当前根的证明,而没有任何事件带 slot,聚合器必须逐字重实现空闲槽分配器(LIFOfreeSlots+ 单调nextSlot+slotPlusOne != 0提前返回)才能重建冻结树,分配器一改就静默失效。修:hook 里发SlotAssigned/SlotCleared。 - [Info]
:56——G1_LEN声明了没用。 - PR body 与代码不一致:body 把线格式写成
nodeId(32) ‖ merkleProof(TREE_DEPTH*32),漏了 slot;header:32-34和perSigner = 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 ≤ 8e9、m ≤ 110⇒≤ 8.8e11,不可能溢出。(这个 setter 的真问题和溢出方向相反 —— 见 B3。) - R1a-3(
_quorumOf在 m=0) —— 逐行追踪:committedCount==0 ⇒ m=0 ⇒ required=0,k<required确实不触发,但T=_thresholdOf(0,·)=0且T != 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 按「能解锁评审」的顺序修,不按发现顺序:
accountIdenrollment 映射(R2 的设计)可以独立合入,不用等 airaccount 那个 draft;而 sortition 曲线得先有一个明写的安全目标才能修 —— 没有目标就没有「修好了」的判据,而 6.64e-6 的渐近线意味着这个构造下没有任何 n 能到 1e-9。 - 把那 25 行 look-ahead 探针原样收进套件,再加一个
requireStake=true版本的 committee 套件。 这两笔一起杀掉全部三个存活变异,而且探针在当前代码上本来就是绿的 —— 只花覆盖率,不花别的。 setOversample补configVersion += 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 轮不够对抗,但它实质性地锐化了 B2:L=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 7d91f963 与 gh 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_COUNT 与 target >= n 塌缩带(over≥5 覆盖 80≤n≤550)逐点核算。
PR-Daemon v4 · 4-round PK pipeline · 结论由 Opus 裁定
…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.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
clestons
left a comment
There was a problem hiding this comment.
❌ 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?
accountId在src/里正好三处(:409读、:413截断、:438全宽 draw),没有第四个消费者。(b) 高 96 位有没有既定语义(chain id / 版本 / 命名空间)? 全仓contracts/src、contracts/test、TS 源码、docs 里>> 160/<< 160/uint96零命中;而同仓的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 的configVersionbump 把 epoch e-1 永久搁浅,用一次保证停摆去买这个修复。这个值必须在构造函数里就是对的。
已确认(follow-up,不阻塞)
- [High]
:277——lastSetMutationBlock守卫在迁移边界上是【零资本】DoS,而那正是本 PR 自己的 interlock 要求走的那道门(Codex 的写入者枚举带出来的,R4 收尾)
这条守卫是我上一轮开的处方,我得认。它的成本我先前算错了 —— 以为要预置 63 个质押 operator。实际看基类:927:owner 一旦把bool stale = isBootstrap[nodeId] ? requireStake : !_isStaked(op);
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——setOversample的configVersionbump 用「砖化」关掉了追溯性,代价没人算过
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]
:309unenroll()—— 单向陷门。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 != 0、requiredQuorum()只看e-1,而validate()需要e和e-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/100→ 110/110 全绿(整个 B1 论证赖以成立的那个值没有测试钉住);把requiredQuorum()的哨兵从type(uint256).max改成0→ 110/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「
_onNodeDeactivated把activeCount减了两次」—— 错的,有据:grep显示全文件恰好一处activeCount -= 1(:230)、一处+= 1(:218),且:226的if (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 之前就会是红的。 lastSetMutationBlock和configVersion两处是同一个反模式:让快照的成立依赖于「世界静止」。两处的解法也是同一个 —— 把值冻进快照(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 6f8daa4d 与 gh 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) 联合扫描三组参数;syncNode 的 isBootstrap ? 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.
clestons
left a comment
There was a problem hiding this comment.
✅ 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→30、oversample 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] 文档里两处数字与实测不符(结论不受影响,但这条线上的数字已经被引用了三轮)
:曲线注释「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% 在线)并注明是哪个在线率。- 同段「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 —— 上一轮已认可的三条:setOversample 的 configVersion bump 仍会造成至少一个 epoch 的停摆(更好的形状是把 oversample 对冻进快照);committeeActive() 只看 epochLength != 0、requiredQuorum() 只看 e-1,而 validate() 需要 e 和 e-1 两个;β≤10% / N≥N0 仍无链上闸门(setEpochLength 不读 activeCount)。
建议
- 合并前只有一件事值得做:把
test_snapshot_freezes_block_start_state的变更次数从 1 改成 2。一行改动,关掉本轮唯一一个落在新代码上的存活变异。 - 两处数字(
5.9e-7、floor 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 817bf360 与 gh 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
left a comment
There was a problem hiding this comment.
❌ 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 < 32 时 signature[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#202 的 F-W11 存在的理由 —— 那一条的全部意义就是「不要在 validation 阶段 revert(ERC-7562)」。同一个规则,一个仓在加固它,另一个仓在同一天把它弄丢了。
前提可达性:epochLength != 0 且 epoch 已 pin 时就会走到这一路,validate 是 external、签名字节由提交者任意构造,没有任何上游保证 length >= 32。
(i) 不变量:
validate()对任何输入都返回0或1,永不 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 闸门之后,这个测试的失败模式变了 —— 现在other走v.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 行探针(同 blockrevokePublicKey×2)当前 PASS、变异下 FAIL,直接收编即可。
建议
- B6 一行顺序改回来就好,然后把我那个长度探针收进套件(0/4/31/32 四个长度)。这条不能带着合 —— 它把一个合约自己写下来的、姊妹仓正在加固的性质弄丢了,而且是在一个自称行为中性的 commit 里。
- B7 一行
if换assertTrue。这条不阻塞合并(代码是对的),但它意味着这个 PR 最核心的机制目前没有回归保护 —— 在它被钉住之前,任何后续改动都可能悄悄弄坏抽签而全绿。 - 两条加上 carried 的两条,都是测试侧的一行到十几行,建议一个 commit 收掉。
各轮结论
| 轮次 | 模型 | 结果 |
|---|---|---|
| R1a/R1b | deepseek-v4-flash | 未跑 —— 增量是纯重构 + 修我自己上一轮的 findings(ABSOLUTE CONSTRAINT #5 豁免②)。源码 173 行全是把 validate() 拆进 block scope 与 helper,测试零改动 |
| 核验 | 本轮主体 | 前后对照实测(同探针换源码文件)证明短签名从 return 1 变 revert;8 个变异 7 红 1 存活;用 assertTrue 探针证明 sanity 守卫恒假;切片等价性逐行核 |
| 裁决 | Sonnet | REQUEST_CHANGES。一条行为回归(阻塞)+ 一条核心机制零覆盖(我上一轮的漏检) |
机械证据:worktree HEAD 59cf455 与 gh pr view 核对一致;forge test 115/115;短签名前后对照(817bf360 4/31 字节均 returned 1;59cf455 均 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.
clestons
left a comment
There was a problem hiding this comment.
✅ 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 字节三个长度),删掉那行守卫 → RED(test_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 行探针(同 blockrevokePublicKey×2,当前代码 pin=5、变异下 pin=4),直接收编即可。批量驱逐正是这个守卫要防的现实形状,所以这条比它看起来重要。
两条合起来是一个提交的量。
各轮结论
| 轮次 | 模型 | 结果 |
|---|---|---|
| R1a/R1b | deepseek-v4-flash | 未跑 —— 增量 = 修我上一轮的两条 findings(ABSOLUTE CONSTRAINT #5 豁免②)。源码 26 行(挪一行守卫),测试 +155 行 |
| 核验 | 本轮主体 | 前后对照探针原样重跑(B6 的发现工具);完整 16 个变异重跑;新测试逐个读 |
| 裁决 | Sonnet | APPROVE。两条 Blocking 关闭,无新增缺陷,剩余两条为跨轮 carried 覆盖项 |
机械证据:worktree HEAD 12cde69 与 gh pr view 核对一致;forge test 117/117;短签名前后对照探针重跑(4/31 字节均 returned 1,与 59cf455 的 REVERT 对照);完整 16 变异(字面量替换 + assert 单点命中,逐个 git checkout -- 还原,末尾 git status 干净)14 红 2 存活;新增三个测试逐行读并各自用变异验证其绑定。
PR-Daemon v4 · 轮数如实标注(R1 按豁免②未跑)
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:
m ≤ 110caps the tail at ~6.6e-6 (nonreaches 1e-9; worst point n=84 = 3.5e-2). Needs DSR's written security target + corrected m/T curve.accountIdis 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 invalidate()(zero extra pairings), DSR m/T curve.Wire format (account prepends accountId; submitter provides the rest):
(slot = the node's leaf index in the FROZEN setRoot[e-1], authenticated by the proof.)
Review trail
Tests
105 total pass (committee suite includes mutation-verified look-ahead + hook discriminators).
NOT in scope / blocked
cc CC-98.