用法:不要一次读完。写到哪一类代码,就翻到对应那一节。 每一条后面的 [N]事故档案的篇号——看不懂某一条为什么重要,就去读那一篇。


第一阶段 · 写代码时

A. 外部调用与控制流

□ A1  每个函数从上到下扫一遍,标出所有【外部调用】那几行     [1]
      对每一行问:这之后控制权会离开我吗?

□ A2  ⭐ 所有状态更新排在所有外部调用【之前】(CEI)          [1][20]
      ⚠️ 这是第一道防线,重入锁只是第二道 ——
         因为锁可能因为编译器 bug 而失效

□ A3  重入锁覆盖【所有读写同一份状态的入口】,              [1][2]
      而不是只加在"看起来危险"的那个函数上

□ A4  ⭐ 对每一对共享同一把锁的函数做交叉重入测试            [20]
      (n 个函数 = n² 对,写个循环)

□ A5  view 函数也要检查:它读的状态会不会被分两步更新?      [3]
      ⚠️ 凡是形如 A/B 的对外读数都要过一遍

□ A6  在回调里读取调用方的状态 ⟹ 一律假设它不自洽           [3]

□ A7  ⭐ 不要用 .transfer() 的 2300 Gas 限制防重入            [1]
      用逻辑防,不要用 Gas 额度防

B. 权限与身份

□ B1  ⭐⭐ 把所有 external/public 函数列成一张表:            [4]
      函数名 | 谁能调 | 改了什么 | 出错的最坏后果
      ⚠️ 填不出某一行 ⟹ 那里漏了访问控制

□ B2  初始化函数:一次性标记 + 参数校验 + 部署时原子完成      [4][7]
      用 OpenZeppelin 的 Initializable,不要手写

□ B3  ⭐⭐ 实现合约构造函数里调 _disableInitializers()       [5]

□ B4  ⚠️ 实现合约里【不能有】selfdestruct 和任意 delegatecall [5]
      部署前扫一遍字节码确认

□ B5  代理设置实现地址时校验 code.length > 0                 [5]

□ B6  ⭐⭐ 持有特权的合约【不能】同时充当通用调用转发器       [6]
      校验身份和执行调用要拆成两个身份,执行方零权限

□ B7  调用的"目标"和"方法"必须来自你自己的代码,不能来自输入 [6]
      ⚠️ 不要从用户字符串拼函数选择器 ——
         4 字节选择器可以被暴力碰撞

□ B8  白名单,不要黑名单                                     [6]

□ B9  权限变更 / 升级 / 预言机地址变更 ⟹ 时间锁 + 事件       [6][17]

□ B10 ⭐ 所有授权【默认带过期时间】,续期需要主动操作        [22]

□ B11 不要用 tx.origin 做身份检查                            [4]

□ B12 签名必须覆盖会影响执行结果的【每一个字节】:           [6][10]
      nonce / chainId / 合约地址 / 过期时间 / 调用哪个函数

C. 数值

□ C1  ⭐ 先检查每一项,再聚合,聚合过程中也要检查            [8][9]
      ⚠️ 任何"先聚合、后校验"的地方,聚合本身就是攻击面

□ C2  ⭐⭐ Solidity 0.8 【不检查】显式类型转换                [9]
      uint128(x) 越界静默截断 ⟹ 用 SafeCast

□ C3  unchecked 块和内联汇编里的算术,逐个人工复核           [9]

□ C4  ⭐ 业务范围检查独立于类型范围                          [8]
      类型不溢出 ≠ 数值合法

□ C5  手写的边界检查必须测 阈值 和 阈值±1                    [11]
      ⚠️ 离边界远的用例无法区分"对"和"差三个数量级"

□ C6  ⭐ 常量要"算出来",不要写死十六进制魔法数              [11]
      1 << 192 一眼能核对,一串掩码不能

□ C7  ⭐⭐ 舍入方向:所有零头都留在协议里                     [19]
      deposit 向下 / mint 向上 / withdraw 向上 / redeem 向下

□ C8  跨代币算术先归一化精度,把基准写进变量名               [8]
      ⚠️ USDC 6 位、WBTC 8 位、WETH 18 位

□ C9  ⭐ 零值不能是有效值                                    [7]
      mapping 对不存在的 key 返回零值 ⟹ 零值必须表示"不存在"
      用带 exists 位的结构体更彻底

□ C10 enum 的第 0 个成员留给 Invalid / Unknown               [7]

D. 外部数据与依赖

□ D1  ⭐⭐ 永远不读现货价                                     [12]
      任何"读一次就能得到"的链上价格都可被操纵

□ D2  ⭐ 内部记账变量作为唯一真相,不要用 balanceOf          [15]
      ⚠️ 任何人都能给你转账,你无法拒绝

□ D3  address(this).balance 可被 selfdestruct 强制改变       [15]

□ D4  多源 + 偏离检测,且数据源必须【底层真独立】            [14]
      ⚠️ 五个源都从同一个 API 取数 = 一个源

□ D5  ⭐⭐ 异常时暂停,而不是照单全收                         [14]
      合约必须能表达"我现在不知道价格"这个状态
      返回错值比拒绝返回危险得多

□ D6  ⭐ 清算要求"持续 N 个区块低于清算线"                   [14]
      过滤瞬时尖刺;N 的取值是显式取舍,不是默认值

□ D7  分档清算,不要一次清完                                 [14]

□ D8  ⭐ 上架资产前算:把它的价格推到 2 倍要多少钱?         [12][16]
      ⚠️ 小于你的敞口 ⟹ 不要上架,无论用多好的预言机

□ D9  自己的代币不能做自己的抵押品                           [16]

□ D10 ⭐ 清算可行性验证:清算人拿到抵押品后卖得掉吗?        [16]
      卖不掉 ⟹ 清算只是一个无法执行的承诺

□ D11 上架代币的兼容性审查(八条)                           [2]
      回调 / 手续费 / rebase / 返回值 / 黑名单 /
      多入口 / 小数位 / ⭐ 可升级

□ D12 ⭐⭐ 允许别人指定代币地址 = 允许任意代码在关键路径执行  [2]

E. 经济与博弈

□ E1  ⭐⭐ 对每一个"按余额/持仓/份额计权"的地方问:          [12][17]
      "如果调用者在这一个区块内拥有无限资金,会怎样?"
      ⟹ 价格、投票权、份额、奖励快照、费率档位、
         任何"持有 X 才能做 Y"的门槛

□ E2  ⭐ 任何在单个区块内可被改变的量,                      [12]
      不能单独决定资金的去向

□ E3  ⭐⭐ 投票权快照的时点必须【早于】提案创建               [17]
      ⟹ 闪电贷因此完全失效

□ E4  ⭐ 通过 ≠ 执行,中间必须有时间锁                       [17]

□ E5  应急通道只能"做减法"(暂停、撤权),                   [17]
      ⚠️ 不能转移资产、不能升级实现

□ E6  ⭐⭐ 任何可能让账户变差的操作,结束前强制健康检查       [18]
      ⚠️ 用架构强制(统一路由),不要靠每个函数自己记得

□ E7  ⭐ "只影响自己"是错觉 ——                              [18]
      有共享风险池时,账户状态是公共状态

□ E8  递增的激励要问:"谁能人为制造触发条件?成本多少?"     [18]

□ E9  清算折扣封顶 + 深度不健康的账户延迟一个区块才可清算    [18]

□ E10 ⭐ 金库防第一存款人:虚拟份额偏移 + require(shares > 0) [19]

第二阶段 · 部署前

□ P1  ⭐ 锁定编译器版本到补丁号,写进部署记录                [20]
      合约地址 | 编译器版本 | commit | 编译参数

□ P2  ⭐⭐ 可复现构建:重新编译,与链上字节码逐字节比对       [20]
      同时防住:环境不一致 / 部署脚本被篡改 / 部署了别的代码

□ P3  CI 里把 deprecation 警告设为【错误】                   [21]

□ P4  ⭐⭐ 初始化参数逐项校验,尤其是零值                     [7]
      零根 / 零地址 / threshold=0 / threshold>成员数

□ P5  ⭐⭐ 负向测试:本该失败的操作是不是真的失败            [7]
      □ 未证明的消息 ⟹ 必须 revert
      □ 伪造的证明   ⟹ 必须 revert
      □ 零值参数     ⟹ 必须 revert
      □ 别人的证明   ⟹ 必须 revert
      ⚠️ 正向测试只证明功能没坏,只有负向测试证明防线还在

□ P6  ⭐ 构造的负例,不只是随机的负例                        [10]
      随机乱改的假证明会失败,精心构造的不会

□ P7  ⭐ 不变量测试(每条不到十行,覆盖面极大):
      □ totalSupply == Σ balances                          [9]
      □ ⭐ 存入后立即取出,必须亏钱                          [13][19]
      □ 往返(AMM / 桥 / 质押)必须亏
      □ 自我清算永不获利                                    [18]
      □ Σ 所有头寸价值 ≤ 池子实际余额                       [11]
      □ ⭐ fuzz 的初始状态必须包含【空】                     [19]

□ P8  ⭐ 按"每行代码控制多少资金"分配审计时间                [10]
      而不是按代码总行数

□ P9  自定义函数比标准函数花更多时间                         [9]
      ⚠️ 名字带 batch/multi/bulk/optimized 的单独审

□ P10 ⭐ fork 代码时,列出原作者做过但没写下来的假设         [2]

第三阶段 · 上线后(⚠️ 最容易被忽略的一段)

F. 密钥与签名流程

□ F1  ⭐⭐ 算一遍多签的【实际门槛】:                        [22]
      按"共享攻击面"给密钥分组,
      实际门槛 = 覆盖 N 把密钥所需的最少组数
      ⚠️ Ronin 名义 5/9,实际 1/1

□ F2  跨组织 / 跨地域 / 跨设备品牌 / 跨密钥方案             [22]

□ F3  ⚠️ 逐条检查有没有"代签 / 委托 / 备用"旁路             [22]

□ F4  ⭐⭐ 冷钱包只能转【白名单地址】                        [25]

□ F5  ⭐⭐ 禁止 delegatecall(operation = 1)                [23][25]
      ⚠️ 日常转账永远不需要它。用守卫模块强制。
      ⭐ 这一行配置能挡住 14.6 亿美元的攻击链

□ F6  大额转出走时间锁                                       [25]
      ⭐ 冷钱包本来就不需要实时性,代价几乎为零

□ F7  单笔 / 单日限额                                        [7][21]

□ F8  ⭐⭐ 签名前用【独立通道】校验 calldata                 [23]
      从链上读原始参数 → 独立工具解析 → 自己算 EIP-712 哈希
      → 与硬件钱包屏幕逐字符比对
      ⚠️ "独立"= 不是同一台设备、不是同一个前端、
         不是同一批开发者维护的工具

□ F9  签名在专用隔离设备上进行                               [24]
      不上网 / 不收邮件 / 只读介质启动 / 与办公网隔离

□ F10 多人校验要用【不同的工具】                             [24]
      ⚠️ 都用同一个前端 = 把同一个错误看了多遍

G. 监控与响应

□ G1  ⭐⭐ 资金余额每分钟核对                                [22]
      几十行代码,把发现时间从【六天】缩短到【一分钟】
      ⟹ 全档案投入产出比最高的一条

□ G2  ⭐ 监控跑在【独立的】基础设施上                        [25]

□ G3  告警能叫醒人(电话/短信,不是邮件)                    [22]

□ G4  ⭐⭐ 有一个【不需要多签】就能按的暂停键                [7]
      ⚠️ 需要多签才能按的暂停 ≈ 没有暂停

□ G5  ⭐ 每季度演练一次                                      [26]
      没演练过的流程等于不存在

□ G6  ⭐⭐ 回答这个问题:凌晨三点谁按暂停键?                [26]
      他怎么被叫醒?他有权限吗?多久能完成?

□ G7  ⭐⭐ 把"检测时间"当成一个指标来跟踪和公布             [26]
      "现在发生一笔异常转账,我们多久发现?"

H. 流程与人

□ H1  ⭐⭐ 任何【计划外】事件必须被记录和解释                [24]
      ⚠️ "签名失败了,重签一下" ⟹ 视为安全事件
      ⭐ 把默认反应从"再试一次"改成"先搞清楚"

□ H2  维护一份异常台账                                       [24]
      ⭐ 同一现象出现第三次而根因栏还是空的 ⟹ 必须处理

□ H3  季度权限复核,输出一张表,逐行签字                     [22]
      ⭐ 对每一条问"现在撤销它,谁会抱怨?"答不上来 ⟹ 撤销

□ H4  ⭐ 每份公开的事故复盘发布后,逐条问"我们有没有同样的问题" [24]
      ⚠️ Radiant 出事时,WazirX 的复盘已经公开三个月

□ H5  反钓鱼培训 + 定期发假钓鱼邮件测试                      [22]

□ H6  ⭐ 假设开发者【会】被钓鱼成功 ——                      [22][25]
      然后问"那之后会发生什么"

□ H7  ⭐⭐ 假设终端已被攻破,然后设计                        [24][25]
      不要依赖"人会看仔细",也不要依赖"设备是干净的"

⭐ 如果只能做十件事

按"每单位成本挡掉的损失"排序:

 1. ⭐⭐ 禁止 delegatecall + 冷钱包白名单                     [25]
       一行配置,对应本档案最大的单起损失

 2. ⭐⭐ 资金余额每分钟核对 + 能叫醒人的告警                 [22]
       几十行代码,把"六天"变成"一分钟"

 3. ⭐⭐ 一个不需要多签就能按的暂停键 + 提款限额             [7]
       唯一在"你还没反应过来"时起作用的机制

 4. ⭐⭐ CEI —— 状态更新排在所有外部调用之前                 [1]

 5. ⭐⭐ 把所有 external/public 函数列成权限表               [4]
       填不出的那一行就是漏洞

 6. ⭐⭐ 永远不读现货价                                      [12]

 7. ⭐⭐ 负向测试 + 那几条不变量                             [7][13][19]
       每条不到十行,覆盖面极大

 8. ⭐⭐ 签名前用独立通道校验 calldata                       [23]

 9. ⭐⭐ 授权默认带过期时间 + 季度权限复核                   [22]

10. ⭐⭐ 算一遍多签的实际门槛                                [22]
       大多数团队算完会吓一跳

⭐⭐ 注意这十条里,只有第 4、6、7 条是"写代码"。

其余七条都在代码之外——而这正是事故档案的损失分布告诉你的事。