# 合约审计报告 — StreakLottery

日期：2026-11-02　｜　范围：`StreakLottery.sol` / `PointsToken.sol` / `gates/*` / `providers/ChainlinkVRFProvider.sol`
方式：**双审制**（独立复审 + 本地 EVM 逐条 POC 复现）　｜　工具：solc 0.8.28（0 warning）、ganache 内存 EVM、viem

> 说明：本机没有 Slither / Mythril，专用技能里也没有合约审计项（`ccg` 依赖的 codex/gemini CLI 未安装），因此采用「独立审计员 + 我写 POC 实证」的双审流程。所有结论都可用下方命令复跑。

---

## 0. 结论摘要

| 级别 | 发现 | 已修复 | 已缓解 | 接受 |
|---|---|---|---|---|
| Critical | 3 | 3 | – | – |
| High | 4 | 1 | 2 | 1 |
| Medium | 10 | 5 | 1 | 4 |
| Low | 8 | 2 | – | 6 |
| Info | 6 | 2 | – | 4 |

**最严重的三条（已全部修复）**：
1. **C-01 二等奖去重失效** —— 只排除一等奖与"上一次"中奖者，同一地址可拿多份二等奖，实测 11 票轮有 3 人空手。
2. **C-02 结算 gas 超回调上限** —— 实测 695,920 / 756,719 gas，而 `callbackGasLimit = 500,000`，主网上每轮必 OOG。
3. **C-03 OPEN 态无逃生口** —— keeper 离线或 VRF 请求 revert 时资金永久冻结。

---

## 1. 逐条发现与处置

### Critical

#### C-01 二等奖去重失效（同一地址可多次中奖）· 已修复
- **位置**：`_settle` 的中奖循环 / 旧 `_pickUnique`（现 `_pickExcluding`）
- **问题**：旧实现只传入 `ex1 = first`、`ex2 = 上一名二等奖`，10 次抽签彼此不做全局去重；32 次重试失败后直接 `return r.players[0]`，连 ex1/ex2 都不检查。
- **后果**：承诺的「11 名不同中奖者」不成立；冷启动期（票数接近 11）几乎每轮都退化。独立复审实测：11 票轮 → 只有 8 个不同地址拿到奖金；2 票轮 → 一等奖得主额外拿走 5 份二等奖，独得 60% 奖池。
- **证据**：`node test/audit-poc.mjs` → 修复前 A1 复现 8/8 轮；修复后 0/8。
- **修复**：改用内存数组 `address[11] picked` 做全量去重；重试 24 次；仍失败则从随机起点线性扫一个未用地址（保证存在，因为 `players.length > n`）；玩家数 ≤ 奖项数时才允许重复（数学上不可避免，已在注释说明）。

#### C-02 结算 gas 超出 Chainlink 回调上限 · 已缓解 + 参数强制
- **位置**：`_settle` 全段；`ChainlinkVRFProvider.callbackGasLimit`
- **问题**：11 名中奖者各需 2 个冷写（`prize` + `isWinner`）≈ 486k 硬下限；实测 11 票轮 695,920、2 票轮 638,016，而默认 `callbackGasLimit = 500,000`。
- **后果**：每轮回调 OOG → 永远开不出奖 → 只能 3 天后 `markStuck` 退款，产品功能上不可用。
- **修复**：
  1. 删除 `isWinner` 映射（省 11×22,100 ≈ 243k），改为「`prize` 永不抹零 + `claimedPrize` 标记」；
  2. `callbackGasLimit` 默认提到 **1,500,000**；
  3. 去重改为有界（不再因重复重试而 gas 无上界）。
- **实测**：15 人轮 settle 从 **756,719 → 659,670** gas（POC A6）。
- **残余**：主网必须把 `callbackGasLimit` 设 ≥1,000,000 并做 200 人压测；已写入上线清单。

#### C-03 OPEN 态无 permissionless 逃生口 · 已修复
- **位置**：`closeRound`（原 `onlyKeeper`）、`markStuck`（只接受 CALLING）
- **问题**：keeper 离线 → 过期轮既不能买、不能结算、也不能 markStuck，全轮冻结。
- **修复**：新增 `closeRoundByAnyone()`，`closeAt + 1h` 宽限期后任何地址可推进轮次（`GATE_GRACE = 1 hours`）。
- **证据**：POC A3 修复后普通地址可代关轮。

### High

#### H-01 中奖不清零连击（与三处文档冲突）· 已修复
- **问题**：`_settle` 从未写 `streak`，与 README / KOLKIT / economics 里「中奖即清零」的对外承诺不一致；赢家可长期保留 2.0x 权重。
- **修复**：在 `_settle` 里给每位中奖者（含余烬得主）写 `streak = 0`，O(1)/人，最多 11 次写。
- **证据**：`test/run-tests.mjs` T5 现在按「上一轮是否中奖」动态断言 1.125x / 1.25x。

#### H-02 owner 可即时改 jackpot 配置 · 已缓解
- **问题**：可设 `(0,0)` 让 jackpot 永不发放；也可把阈值压低后定点引爆。
- **修复**：`require(trigger > 0 || every > 0)`；`require(trigger == 0 || trigger > jackpot)`（不能把阈值设到当前池子以下）；`every ≤ 60`；新增 `MAX_FIRE_ROUNDS = 60` 硬兜底（任何配置下 60 轮内必发一次）。
- **额外**：`owner` 被 `TEAM_EXCLUDED` 禁止购票，无法成为中奖者，从根上堵住自肥路径。
- **残余**：owner 仍能调整发放节奏（不能吞掉资金）。

#### H-03 随机源可换 · 接受（有时间锁 + 适配器约束）
- 换源需 `proposeRandomness` → 48h → `commitRandomness`。主网部署必须使用 ChainlinkVRFProvider（owner 无法指定 seed）。
- **运维要求**：`randomnessProvider` 的 owner 权限交给多签，且不得部署任何「可设 seed」的 provider。

#### H-04 门禁内部规则无时间锁 · 接受（写入运维约束）
- `AgentIdGate.setMinBalance`、`AllowlistGate.setAllowed` 即时生效，理论上可定向审查玩家（打断连击）。
- **约束**：主网门禁合约必须自带时间锁；已写入上线清单。

### Medium

| 编号 | 问题 | 处置 |
|---|---|---|
| M-01 | `liabilities()` 只返回 jackpot，对账式在 STUCK 轮误报 | **文档修正**：对账改为三项式（见 §2），`liabilities()` 仅作 jackpot 别名 |
| M-02 | 空轮在 jackpot 判定前 return，`(id+1)%every` 会用绝对轮号吞掉档期 | **已修复**：改为相对计数 `id + 1 - lastFireRound >= jackpotEvery` |
| M-03 | `markStuck` permissionless 且不可逆 | 接受（3 天窗口 + 只在 CALLING 态可用） |
| M-04 | 中奖地址被 USDT 拉黑 → 永久沉淀 | **已修复**：新增 `claimTo(id, to)` |
| M-05 | MCP `proof` 调用不存在的 `rounds()` getter | **已修复**：新增 `roundProof()` view，MCP 已改用它 |
| M-06 | `roundInfo` 缺 random/grand 等字段，公平性无法链上自证 | **已修复**：`roundProof` 返回 requestId/random/first/grand/grandAmt/pot/tickets/target/status |
| M-07 | `fulfillRandomness` 不绑定 requestId | 接受（CALLING 状态是唯一不变量，已注释） |
| M-08 | gate revert 会冒泡阻断全员购票 | 接受（fail-closed；门禁内部已 try/catch） |
| M-09 | 构造仅要求 `gateDelay ≥ 1 minutes` | 上线清单强制 48h（部署脚本默认 48h） |
| M-10 | 绝对时间栅格：结算延迟会吃掉下一轮窗口 | 接受（keeper 追轮 + 自愈），已在 RUNBOOK 说明 |

### Low / Info（摘要）

已修：`points != address(0)` 构造校验；前端领取支持「本轮 + 上一轮」。
接受：`secondCount` 恒 10、`refund` 复用 `Claimed` 事件、索引器 PK 少记重复二等奖（已随 C-01 消失）、无 SafeERC20（USDT 标准实现）、`rollBps` 向下截断（cosmetic）、`ROLL_BPS_*` 常量冗余、`_pickOne` 的 `totalW==0` 分支。
需实测确认：Chainlink VRF v2.5 的 `extraArgs: ""` 是否被接受（Mock 不覆盖）——**上线前必须用真实订阅在测试网跑通**。

---

## 2. 资金守恒（三项式）

旧文档写的两项式 `余额 = Σ未领奖金 + jackpot` **在 STUCK 轮为假**（漏了"已收票款但尚未退款"的部分）。正确的是：

```
balance = Σ 未领取 prize
        + jackpot
        + Σ_stuck ( tickets × price − 已退款 )
        + 尘量 dust            // secondSum mod 10，USDT 18 位下恒为 0
```

- 单轮：`credited + Δjackpot = pot − dust`
- 触发轮：`pay + jfee = jackpot_prev + roll`，`jackpot` 归零后等式仍成立
- 实测复核（POC A5）：`120.0 = 120.0 + 0`，差额 **0 wei**
- 重复/超额领取：`claim` / `claimTo` / `refund` / `claimPoints` 全部「先置位后转账」，且 STUCK 与 SETTLED 状态互斥 → 无路径

---

## 3. 复跑方式

```bash
node scripts/compile.mjs          # 0 warning
node test/run-tests.mjs           # 36 passed（含中奖清零/暂停/代关轮断言）
node test/indexer-e2e.mjs         # 16 passed（索引 + 战报 + 官网数据）
node test/audit-poc.mjs           # 7 项 POC：6 已修复 / 1 参数强制（gas）
```

`out/audit-poc.json` 保留每次 POC 的机器可读结果。

---

## 4. 残余风险（必须由外部审计 + 主网演练覆盖）

1. **Chainlink VRF 真实集成**：`extraArgs`、订阅余额、callback gas、`rawFulfillRandomWords` 的 coordinator 校验——Mock 全覆盖不到。
2. **200+ 人轮的 settle gas**：当前只测到 15 人；需按目标票数压测并据此定 `callbackGasLimit`。
3. **门禁合约的安全性**（Agent ID NFT 的 `balanceOf` 语义、合约迁移）——依赖外部合约。
4. **owner/keeper 密钥管理**：多签 + 硬件钱包 + KMS；`pause` 只能停购票。
5. **经济层面的对抗**（多人合谋分奖、KOL 拉盘后撤池）——不在代码范围。

## 5. 建议的外部审计范围

`StreakLottery.sol`（重点 `_settle` / `_pickExcluding` / `claim*` / 时间锁）、`ChainlinkVRFProvider.sol`、`PointsToken.sol` 的兑换与转让开关、门禁合约的审查能力。预算 $15–25k，2 周，交付物要求附带 PoC。
