十五、从源码讲解compound v2 Compound v2 安全设计与典型风险点总结
Compound v2 安全设计与典型风险点总结
这一讲不再读单个函数,而是把前面所有模块串成一张安全地图。
Compound v2 的安全设计有一个很鲜明的风格:
业务逻辑并不花哨,
但到处都有防线:
先计息、再检查、再执行;
先问 Comptroller;
先确认 fresh;
先查 cash;
小数用定点数;
升级走 Timelock;
清算受 closeFactor 限制。
它不是靠某一个“神级防御”保安全,而是靠很多小防线叠起来。
第十五讲:安全设计与典型风险点
简介
本文从安全视角复盘 Compound v2 的核心设计,把 nonReentrant、accrueInterest、freshness check、Comptroller allowed hooks、cash check、Oracle、定点数、代理升级和 Timelock 治理串成一张风险地图。读完可以理解 Compound v2 不是靠单点防御保证安全,而是通过执行层、风控层、价格层、数学层和治理升级层的多重防线降低协议风险。
1. Compound v2 的核心安全边界
先列总图:
CToken 层:
nonReentrant
accrueInterest
freshness check
cash check
doTransferIn / doTransferOut
borrowRate max check
exchangeRate 计算
account snapshot
Comptroller 层:
mintAllowed
redeemAllowed
borrowAllowed
repayBorrowAllowed
liquidateBorrowAllowed
seizeAllowed
transferAllowed
getAccountLiquidity
collateralFactor
closeFactor
liquidationIncentive
pauseGuardian
Oracle 层:
getUnderlyingPrice
price != 0
decimals 处理
价格抗操纵
Math 层:
Exp / Double
mantissa
truncate
overflow / underflow
Governance / Proxy 层:
Unitroller / Delegator
storage layout
admin 权限
Timelock
proposal / voting / execution
你可以把 Compound v2 的安全看成五层:
执行安全
风控安全
价格安全
数学安全
治理升级安全
2. nonReentrant:防重入
Compound v2 的核心用户操作通常带:
nonReentrant
比如:
mint
redeem
borrow
repayBorrow
liquidateBorrow
为什么?
因为这些函数会和外部合约交互:
ERC20 transferFrom
ERC20 transfer
ETH transfer
cTokenCollateral.seize
外部调用就有重入风险。
典型重入攻击是:
合约 A 还没更新状态
外部合约 B 回调进来
再次调用 A
利用旧状态重复提款或借款
Compound 的防线:
nonReentrant 修饰器
Checks-Effects-Interactions
关键状态先更新再外部转出
但注意,doTransferIn 场景必须先转入再更新状态,因为要知道 actual amount。
所以不能死背 CEI,要结合业务。
3. Checks-Effects-Interactions
Compound 很多函数遵循:
Checks:
检查权限、fresh、cash、参数
Effects:
更新本合约状态
Interactions:
调外部合约转账
比如 borrow:
Checks:
comptroller.borrowAllowed
freshness check
cash check
Effects:
更新 accountBorrows
更新 totalBorrows
Interactions:
doTransferOut
redeem:
Checks:
exchangeRate
redeemAllowed
cash check
Effects:
totalSupply -= redeemTokens
accountTokens -= redeemTokens
Interactions:
doTransferOut
为什么这个顺序重要?
因为外部转账最危险,尽量放最后。 如果外部交互中出现异常或重入,状态已经先更新,不容易重复利用旧状态。
4. accrueInterest:所有动作前先结算市场状态
Compound v2 最大的安全/公平性前提之一:
核心动作前先 accrueInterest()
如果不这么做,会出现:
mint 用旧 exchangeRate,稀释老供应者
redeem 用旧 exchangeRate,取款不公平
borrow 用旧 borrowIndex,债务记录错误
repay 用旧债务,少还利息
liquidate 用旧债务或旧抵押汇率,清算错误
所以我们看到:
mintInternal -> accrueInterest -> mintFresh
borrowInternal -> accrueInterest -> borrowFresh
redeemInternal -> accrueInterest -> redeemFresh
repayBorrowInternal -> accrueInterest -> repayBorrowFresh
liquidateBorrowInternal -> 两个市场 accrueInterest -> liquidateBorrowFresh
accrueInterest 不只是收益逻辑,也是安全边界。
5. freshness check:防止旧状态操作
几乎所有 Fresh 函数都有:
require(accrualBlockNumber == getBlockNumber())
含义:
我只接受已经计息到当前区块的市场状态。
这是一道防御式检查。
即使外层已经调用 accrueInterest(),内层仍然检查。
为什么?
防止内部路径绕过
防止 accrueInterest 失败后继续执行
防止未来升级破坏调用顺序
保证 Fresh 函数语义清晰
清算里更严格:
借款市场 fresh
抵押市场也 fresh
因为清算同时依赖债务和抵押品 exchangeRate。
6. Comptroller allowed hooks:统一风控入口
CToken 不自己判断复杂风险,而是问 Comptroller:
mintAllowed
redeemAllowed
borrowAllowed
repayBorrowAllowed
liquidateBorrowAllowed
seizeAllowed
transferAllowed
这个设计非常关键。
因为账户风险不是单市场问题:
用户可能存 ETH
借 DAI
又存 WBTC
又借 USDC
单个 cDAI 合约不知道用户全局状态。 所以风控必须放在 Comptroller。
几个关键判断:
borrowAllowed:
新借款后是否 shortfall?
redeemAllowed:
取走抵押后是否 shortfall?
transferAllowed:
转走 cToken 后是否 shortfall?
liquidateBorrowAllowed:
borrower 是否已有 shortfall?
repayAmount 是否 <= closeFactor?
seizeAllowed:
两个市场是否属于同一个 Comptroller?
一句话:
CToken 做局部状态变更;
Comptroller 看全局风险。
7. cash check:有权取不代表池子有钱
borrow 和 redeem 都要检查:
getCashPrior() >= amount
Borrow:
市场要转钱给借款人
必须 cash >= borrowAmount
Redeem:
市场要把 underlying 还给供应者
必须 cash >= redeemAmount
为什么不能用 totalAssets?
因为:
totalAssets = cash + totalBorrows - reserves
totalBorrows 是借出去的钱,不在合约里。
所以:
账户健康 + cToken 足够
不等于
当前池子现金足够
这就是流动性风险。
8. doTransferIn / doTransferOut:兼容非标准 ERC20
Compound 没有直接裸写:
token.transferFrom(...)
token.transfer(...)
而是封装:
doTransferIn
doTransferOut
主要防:
ERC20 不返回 bool
ERC20 返回 false
fee-on-transfer token 实际到账少
转账失败但不规范 revert
doTransferIn 特别重要,因为它返回:
actualAmount
也就是实际到账金额。
mint / repay / liquidate 都应该按 actual amount 计算:
mint:
actualMintAmount 决定 mintTokens
repay:
actualRepayAmount 决定债务减少
liquidate:
actualRepayAmount 决定 seizeTokens
否则会被手续费 token 或奇怪 token 打穿。
9. 但非标准 ERC20 仍然是风险
封装不是万能的。
一些 token 可能有:
rebasing
blacklist
pausable transfer
fee-on-transfer
callback
balanceOf 异常
transfer 限制
通缩/增发机制
这些行为可能破坏 Compound 的假设。
Compound v2 的核心假设更适合:
行为相对标准、余额可预测、转账可靠的 ERC20。
所以新增市场必须谨慎。 治理支持一个新资产,不只是加个地址,而是在引入这个 token 的全部奇葩行为。ERC20 标准像菜单,很多 token 像厨师自由发挥。
10. borrowRate max check:限制荒谬利率
accrueInterest() 中通常有:
require(borrowRateMantissa <= borrowRateMaxMantissa)
防什么?
防利率模型返回离谱值。
如果利率模型配置错误,返回每区块超高利率:
borrowIndex 暴涨
totalBorrows 暴涨
用户债务瞬间爆炸
清算大面积触发
所以 CToken 说:
我会调用外部 InterestRateModel,
但它返回的 borrowRate 不能超过上限。
这是模块化系统里很经典的二次防线。
11. exchangeRate 安全
exchangeRate 公式:
exchangeRate =
(cash + totalBorrows - totalReserves) / totalSupply
风险点:
cash 是否正确
totalBorrows 是否 fresh
totalReserves 是否正确
totalSupply 是否为 0
rounding 是否合理
第一次 mint 时用:
initialExchangeRateMantissa
避免分母为 0。
mint 时必须先算 exchangeRate,再 doTransferIn:
否则用户自己的存款会影响自己的 mint 价格。
redeem 时必须先 accrueInterest:
否则用旧 exchangeRate 取款。
exchangeRate 是供应侧收益和抵押价值的核心,一错全错。
12. borrowIndex / BorrowSnapshot 安全
借款债务靠:
principal * currentBorrowIndex / interestIndex
风险点:
borrow 前不更新 borrowIndex
repay 前不更新 borrowIndex
interestIndex 记录错误
principal 更新顺序错误
borrowIndex 溢出或荒谬增长
Compound 的防线:
所有借还前 accrueInterest
每次 borrow / repay 后更新 principal 和 interestIndex
borrowRate max check
安全数学
这个模型避免遍历用户,但前提是每次用户操作都正确更新快照。
13. Oracle price check
Comptroller 在账户健康度和清算计算中依赖:
oracle.getUnderlyingPrice(cToken)
如果价格是 0,通常报错:
PRICE_ERROR
为什么?
因为价格为 0 时继续计算非常危险:
抵押资产价格 0:
用户可能被错误清算
借款资产价格 0:
用户借款价值被低估,可能借过量
清算价格 0:
seizeTokens 计算失真
Oracle 是 Compound v2 最大外部依赖之一。
14. Oracle 操纵风险
价格系统的典型风险:
抵押资产被高估
借款资产被低估
抵押资产被低估导致误清算
借款资产被高估导致误清算
价格延迟
decimals 处理错误
流动性差资产价格被操纵
fallback oracle 逻辑错误
借贷协议的坏账攻击常见路径:
操纵抵押资产价格上升
-> 抵押价值虚高
-> 借出真实资产
-> 价格恢复
-> 留下坏账
所以新增抵押资产时,不能只看 token 市值,还要看:
价格源质量
DEX / CEX 流动性
波动性
可操纵成本
清算深度
15. collateralFactor:资产风险缓冲
抵押价值不是直接按市价算,而是乘:
collateralFactor
公式:
collateralValue =
underlyingAmount * price * collateralFactor
collateralFactor 是协议对资产风险的折扣。
风险越高,应该越低:
稳定币 / 高流动性资产:较高
波动大资产:较低
长尾资产:很低甚至 0
如果 collateralFactor 设置过高:
价格轻微下跌就可能坏账
清算人来不及清算
协议风险上升
如果设置为 0:
用户可以供应赚利息
但不能用作抵押借款
这是新增市场常见安全做法。
16. closeFactor:限制单次清算规模
清算不能无限还:
maxRepay = borrowBalance * closeFactor
closeFactor 的作用:
限制单次清算规模
避免一次性过度清算
控制市场冲击
但它也有权衡。
closeFactor 太低:
坏账账户需要多次清算
极端行情下可能清不够快
closeFactor 太高:
用户可能被一次性大规模清算
清算冲击更大
这是风险参数,不是纯技术参数。
17. liquidationIncentive:清算激励
清算人获得:
repayValue * liquidationIncentive
如果 incentive 太低:
清算人没利润
没人愿意清算
坏账扩大
如果 incentive 太高:
用户清算损失太大
恶意清算动机更强
协议体验变差
清算激励要覆盖:
gas
滑点
价格风险
竞争成本
无法立即 redeem 的流动性风险
它是协议自我修复机制的奖励预算。
18. seizeAllowed:跨市场一致性
清算时,一个市场还债,另一个市场转抵押品:
cDAI.liquidateBorrow(...)
-> cETH.seize(...)
seizeAllowed 要检查两个市场属于同一个 Comptroller。
否则可能出现:
A 风控系统判断可以清算
B 抵押市场却属于另一个风控系统
价格、参数、账户状态不一致
所以跨市场操作必须在同一风控域内。
19. cToken transferAllowed:cToken 不是普通 ERC20
cToken 是 ERC20,但转账要受 Comptroller 限制。
如果 Alice 的 cETH 是抵押品,转出 cETH 等价于降低抵押。
所以:
transferAllowed 会模拟转出后是否 shortfall
如果转出会导致借款抵押不足,就拒绝。
这和 redeemAllowed 类似。
所以:
cToken 可转账,
但不是无条件可转账。
这点对集成协议很重要。
20. pauseGuardian:紧急刹车
Comptroller 有暂停机制:
pauseGuardian
可以暂停某些操作:
mint
borrow
transfer
seize
用途:
预言机异常
资产合约异常
市场攻击
治理来不及处理
实现漏洞
pauseGuardian 是快速响应机制。
但它也有中心化风险,所以通常权限有限:
可以暂停高风险操作
不能随便偷资产
不能任意改核心参数
好的紧急权限应该像灭火器:能救火,但不能拿来装修房子。
21. Math 安全:mantissa / rounding / overflow
Compound v2 大量使用:
Exp
Double
mantissa
1e18
1e36
truncate
风险点:
小数缩放错误
decimals 处理错误
乘法溢出
除法截断
rounding 方向不合理
不同精度混用
典型高危:
USDC 6 decimals
DAI 18 decimals
WBTC 8 decimals
cToken 8 decimals
oracle price mantissa
exchangeRate mantissa
只要错一个 1e12,协议就变抽象艺术。
Compound 的防线是统一的数学库和明确 mantissa 约定。 但读源码或做 fork 时,最容易出事的仍然是 decimals。
22. truncate 的保守性
很多计算向下取整:
truncate
它通常更保守:
少 mint 一点
少 redeem 一点
少分一点 reward
但也会产生 dust。
审计时要关注:
rounding 是否可能被反复利用?
dust 是否会累积?
某方向取整是否损害协议或用户?
大多数情况下,Compound 的取整选择偏保守,但 fork 改公式时很容易破坏这个平衡。
23. Proxy storage layout:升级最大技术风险
代理升级里,状态在 proxy,逻辑在 implementation。
风险:
新 implementation storage layout 不兼容
后果:
oracle 地址读错
admin 读错
markets mapping slot 变了
用户余额错乱
借款状态损坏
规则:
不能重排旧变量
不能删除旧变量
不能改旧变量类型
新变量只能追加
继承顺序不能乱改
Compound 用一系列 Storage 合约固定布局:
ComptrollerV1Storage
ComptrollerV2Storage
...
这是代理协议的生命线。
24. Governance / Admin 风险
可升级协议的最大非技术风险:
谁能升级?
谁能改参数?
谁能换 oracle?
谁能支持新市场?
如果治理或 admin 被攻击,可以:
换恶意 Comptroller implementation
换恶意 cToken implementation
换恶意 Oracle
把 collateralFactor 调到危险值
把 reserveFactor 改极端
支持恶意市场
暂停关键功能
Compound 的治理防线:
COMP voting
proposalThreshold
quorumVotes
Timelock delay
公开 calldata
GovernorAlpha 状态机
Timelock 很关键:
即使恶意提案通过,也不会立刻执行,
市场有时间反应。
25. Timelock 不是万能
Timelock 只提供时间缓冲,不自动判断好坏。
如果用户不看提案,或者治理被捕获:
Timelock 到期后恶意操作仍然会执行。
所以治理安全还依赖:
社区监控
安全团队
提案模拟
参数审查
投票参与
前端展示 calldata
链上预警
链上治理很酷,但它不替你读合约。残酷但公平。
26. COMP 分发风险
COMP Flywheel 本身是 index 设计,很优雅,但经济上有风险:
循环借贷
虚假 TVL
负利率借款
短期流动性挖矿资金
激励结束后撤离
奖励分发精度 dust
COMP 价格波动影响用户行为
如果 COMP 奖励过高,用户可能为了挖矿做高杠杆循环:
supply -> borrow -> supply -> borrow
这会放大协议系统性风险。
27. Market listing 风险
新增市场是 Compound v2 最敏感的治理动作之一。
新增一个 cToken 市场要评估:
underlying token 是否标准
价格源是否可靠
流动性是否足够
是否能被操纵
是否适合作为抵押
collateralFactor 应该是多少
reserveFactor 应该是多少
interestRateModel 是否合适
清算市场深度是否足够
很多借贷协议事故不是核心代码错,而是:
支持了错误资产
给了过高抵押因子
用了脆弱 oracle
参数是代码的另一半。别只审 Solidity,不审风险参数。
28. 典型攻击 / 事故路径
路径一:Oracle 操纵借款
操纵抵押资产价格上涨
-> 存入被高估资产
-> 借出真实资产
-> 价格恢复
-> 账户坏账
防线:
高质量 oracle
低 collateralFactor
供应/借款上限
pauseGuardian
清算激励
市场监控
路径二:治理恶意升级
攻击者获得足够投票权
-> 提交恶意 implementation
-> 提案通过
-> Timelock 到期
-> 升级后窃取资产
防线:
quorum
proposalThreshold
投票委托分散
Timelock
社区监控
治理提案模拟
限制可借治理代币影响
路径三:非标准 ERC20 破坏记账
fee-on-transfer token
rebasing token
blacklist token
转账前后余额异常
防线:
doTransferIn actual amount
谨慎 listing
不给高 collateralFactor
适配特殊 token
路径四:清算不及时
价格剧烈下跌
-> 账户 shortfall
-> 清算激励不足或市场流动性不足
-> 清算人不愿意清算
-> 坏账扩大
防线:
合理 liquidationIncentive
合理 closeFactor
深流动性资产
价格及时更新
清算机器人生态
风险参数保守
路径五:代理升级 storage 错误
新实现改变 storage layout
-> 升级后关键变量错位
-> 协议异常
防线:
Storage 合约继承
升级审计
storage layout diff
测试主网 fork
Timelock 审查
29. 一条操作的安全防线示例:borrow
Alice 调用 borrow,经过:
1. nonReentrant 防重入
2. accrueInterest 更新市场状态
3. freshness check 确保 fresh
4. Comptroller.borrowAllowed 检查全局抵押
5. Oracle price 检查资产价值
6. collateralFactor 折扣抵押
7. cash check 确保池子有钱
8. borrowBalanceStored 计算旧债务
9. 更新 accountBorrows 快照
10. 更新 totalBorrows
11. doTransferOut 转出资产
12. Borrow event 记录
这不是一个 if 能保护的,是一整条防线链。
30. 一条清算的安全防线示例
Bob 清算 Alice:
1. 借款市场 nonReentrant
2. 借款市场 accrueInterest
3. 抵押市场 accrueInterest
4. 两个市场 freshness check
5. liquidateBorrowAllowed
6. 检查 borrower shortfall
7. 检查 repayAmount <= closeFactor
8. 检查 borrower != liquidator
9. 禁止 repayAmount 0 / uint(-1)
10. doTransferIn 清算人实际还款
11. actualRepayAmount 更新债务
12. Oracle 价格计算 seizeTokens
13. liquidationIncentive 计算奖励
14. seizeAllowed 检查两个市场归属
15. 抵押 cToken 从 borrower 转给 liquidator
16. event 记录
清算是 Compound v2 安全设计的集大成者。
31. 安全审计时的阅读顺序
如果你以后审一个 Compound v2 fork,我建议按这个顺序:
1. CToken 核心流程:
mint / redeem / borrow / repay / liquidate
2. Comptroller:
getAccountLiquidity
allowed hooks
参数设置函数
3. Oracle:
getUnderlyingPrice
decimals
fallback
价格源
4. InterestRateModel:
borrowRate 上限
utilization 公式
极端利用率
5. Token handling:
doTransferIn / doTransferOut
underlying token 特性
6. Proxy:
storage layout
implementation upgrade
admin 权限
7. Governance:
Timelock
proposal 执行权限
参数修改路径
8. Rewards:
reward index
claim
precision / dust
32. Compound v2 安全设计总口诀
可以这么记:
先计息,再操作;
先问风控,再动钱;
用 fresh 防旧状态;
用 cash 防流动性幻觉;
用 oracle 定价值;
用 factor 打折抵押;
用 closeFactor 限清算;
用 liquidationIncentive 激励修复;
用 nonReentrant 防重入;
用 Timelock 管升级;
用 storage layout 保代理;
用 actualAmount 防奇葩 token。
这段背下来,Compound v2 的安全哲学就有了。
33. 第十五讲要记住的 10 个结论
第一,Compound v2 的安全来自多层防线,而不是单个检查。
第二,核心操作前 accrueInterest() 是公平性和安全性的基础。
第三,freshness check 防止使用旧市场状态执行敏感操作。
第四,所有高风险操作都要经过 Comptroller 的 allowed hooks。
第五,cash check 解决的是“合约当前有没有钱”,不是账户有没有权利。
第六,Oracle 是借贷协议的核心风险源,价格错误会影响借款、取款和清算。
第七,清算参数 closeFactor 和 liquidationIncentive 是坏账控制和用户损失之间的平衡。
第八,非标准 ERC20、decimals、rounding 是 DeFi fork 事故高发区。
第九,代理升级的 storage layout 绝对不能乱。
第十,治理和 Timelock 是可升级协议的最终安全边界。
最后一讲 源码总复盘 + 真实交易 walkthrough。
我们选一条完整链路,比如:
Alice 存 ETH
Alice enterMarkets
Alice 借 DAI
DAI 市场 accrueInterest
Alice 还一部分 DAI
ETH 价格下跌
Bob 清算 Alice
Bob 拿到 cETH
Bob redeem cETH
然后把每一步对应到源码函数、状态变量变化、Comptroller 检查和事件。这样你就能从“看懂单个函数”升级到“看懂协议运行”。