十四、从源码讲解compound v2 COMP 分发机制 / Flywheel 激励系统
COMP 分发机制 / Flywheel 激励系统
这讲很有意思,因为它和前面的 borrowIndex 思想非常像:
borrowIndex:
不遍历所有借款人,也能让债务随时间增长
COMP reward index:
不遍历所有供应者/借款人,也能给用户累计 COMP 奖励
Compound v2 里这套奖励系统也常被叫做 Flywheel,飞轮机制。
第十四讲:COMP 分发机制 / Flywheel
简介
本文精读 Compound v2 的 COMP 分发机制,也就是 Flywheel 激励系统,梳理协议如何通过 compSupplyState、compBorrowState、用户 index snapshot 和 compAccrued,在不遍历所有用户的情况下累计供应者和借款人的 COMP 奖励。读完可以理解这套全局 index 机制和 borrowIndex 的相似之处,以及 claimComp 如何把已累计奖励真正转给用户。
1. 为什么需要 COMP 分发?
Compound v2 早期引入 COMP 激励,是为了奖励协议用户。
主要奖励两类人:
供应者 supplier
借款人 borrower
也就是说,某个市场里:
用户供应资产,可以获得 COMP
用户借出资产,也可以获得 COMP
这样做的目的:
吸引流动性
刺激借款需求
把治理代币分发给真实协议用户
推动协议去中心化治理
所以 COMP 分发不是普通空投,而是和协议使用行为绑定。
2. 奖励分发的难题
假设 cDAI 市场每个区块分发:
0.5 COMP 给供应者
0.5 COMP 给借款人
问题来了:
供应者可能有几万人
借款人也可能有几万人
如果每个区块都遍历所有用户发 COMP:
for user in allSuppliers:
user.comp += ...
链上直接炸。gas 不允许。
所以 Compound 又用了熟悉的套路:
全局 index + 用户 index snapshot
这和 borrowIndex 是同一个哲学:
不主动遍历所有用户;
只在用户交互时,按 index 差值结算属于他的奖励。
3. 两类奖励 index
Compound v2 对每个市场维护两套奖励状态:
供应侧奖励状态
借款侧奖励状态
常见结构:
struct CompMarketState {
uint224 index;
uint32 block;
}
然后有两个 mapping:
mapping(address => CompMarketState) public compSupplyState;
mapping(address => CompMarketState) public compBorrowState;
含义:
compSupplyState[cToken]:
这个市场供应侧的全局 COMP index
compBorrowState[cToken]:
这个市场借款侧的全局 COMP index
每个市场都有自己的供应奖励 index 和借款奖励 index。
比如:
cDAI supply index
cDAI borrow index
cETH supply index
cETH borrow index
cUSDC supply index
cUSDC borrow index
注意,这里的 compBorrowState.index 和 CToken.borrowIndex 不是同一个东西。
CToken.borrowIndex:
用来计算借款利息
compBorrowState.index:
用来计算借款人 COMP 奖励
名字容易撞车,脑子里一定分开。
4. 用户侧 index snapshot
为了知道某个用户上次结算到哪里,Compound 还维护:
mapping(address => mapping(address => uint)) public compSupplierIndex;
mapping(address => mapping(address => uint)) public compBorrowerIndex;
可以理解成:
compSupplierIndex[cToken][supplier]:
某用户在某市场供应侧上次结算时的 supply index
compBorrowerIndex[cToken][borrower]:
某用户在某市场借款侧上次结算时的 borrow reward index
还有用户已累计但未领取的 COMP:
mapping(address => uint) public compAccrued;
含义:
compAccrued[user]:
用户已经累计、但还没转出的 COMP 数量
5. 奖励速度:compSpeeds
每个市场有一个 COMP 分发速度:
mapping(address => uint) public compSpeeds;
意思是:
compSpeeds[cToken] = 这个市场每个区块分发多少 COMP
早期版本可能供应者和借款人共用一个速度,后续版本可能拆成:
mapping(address => uint) public compSupplySpeeds;
mapping(address => uint) public compBorrowSpeeds;
概念一样:
supplySpeed:
每区块给该市场供应者分多少 COMP
borrowSpeed:
每区块给该市场借款人分多少 COMP
不同市场的速度可以不同。
例如:
cDAI: 0.4 COMP / block
cUSDC: 0.3 COMP / block
cETH: 0.1 COMP / block
治理可以调整这些速度,把激励导向不同市场。
6. 供应侧奖励怎么分?
假设 cDAI 供应侧每区块分发:
0.5 COMP
从上次更新到现在过了 10 个区块:
blockDelta = 10
那么供应侧总共新增奖励:
compAccruedForMarket = 0.5 * 10 = 5 COMP
这 5 COMP 要按用户 cDAI 持仓比例分给所有供应者。
如果市场总 cDAI 供应量是:
totalSupply = 100,000 cDAI
那么每 1 cDAI 对应的新增 COMP:
ratio = 5 COMP / 100,000 cDAI
这个 ratio 会加到全局 supply index 上:
supplyIndex += ratio
之后用户领取时:
用户奖励 = 用户 cToken 余额 * (当前 supplyIndex - 用户 supplierIndex)
这就是供应侧奖励 flywheel。
7. updateCompSupplyIndex
供应侧更新函数大概叫:
updateCompSupplyIndex(address cToken)
简化逻辑:
function updateCompSupplyIndex(address cToken) internal {
CompMarketState memory supplyState = compSupplyState[cToken];
uint supplySpeed = compSupplySpeeds[cToken];
uint blockNumber = getBlockNumber();
uint deltaBlocks = blockNumber - supplyState.block;
if (deltaBlocks > 0 && supplySpeed > 0) {
uint supplyTokens = CToken(cToken).totalSupply();
uint compAccrued = deltaBlocks * supplySpeed;
uint ratio = supplyTokens > 0
? compAccrued / supplyTokens
: 0;
supplyState.index = supplyState.index + ratio;
supplyState.block = blockNumber;
compSupplyState[cToken] = supplyState;
} else if (deltaBlocks > 0) {
supplyState.block = blockNumber;
compSupplyState[cToken] = supplyState;
}
}
真实源码里 ratio 会放大,比如用 Double 的 1e36 精度:
ratio = compAccrued * 1e36 / supplyTokens
因为 COMP 分到每个 cToken 上通常非常小,如果只用普通整数会被截断成 0。
8. 为什么奖励 index 常用 1e36?
前面讲 Exp 是 1e18。
埋 COMP 分发 index 常用更高精度,比如:
Double = 1e36
原因是:
每个区块分给每个 cToken 的 COMP 很小
例如:
每区块 0.5 COMP
总供应 100,000,000 cTokens
每个 cToken 每区块分到:
0.000000005 COMP
如果精度不够,很容易被整数除法截断成 0。
所以奖励系统用更高精度来减少 dust。
9. distributeSupplierComp
更新全局 supply index 后,还要给具体用户结算。
函数大概叫:
distributeSupplierComp(address cToken, address supplier)
简化逻辑:
function distributeSupplierComp(address cToken, address supplier) internal {
CompMarketState memory supplyState = compSupplyState[cToken];
uint supplyIndex = supplyState.index;
uint supplierIndex = compSupplierIndex[cToken][supplier];
compSupplierIndex[cToken][supplier] = supplyIndex;
if (supplierIndex == 0 && supplyIndex >= compInitialIndex) {
supplierIndex = compInitialIndex;
}
uint deltaIndex = supplyIndex - supplierIndex;
uint supplierTokens = CToken(cToken).balanceOf(supplier);
uint supplierDelta = supplierTokens * deltaIndex / 1e36;
compAccrued[supplier] += supplierDelta;
}
核心公式:
supplierDelta =
supplier cToken balance
* (globalSupplyIndex - userSupplierIndex)
然后除以精度。
这就是:
用户从上次结算到现在,因为持有 cToken 应得多少 COMP。
10. 供应侧例子
假设 cDAI supply index 上次是:
1.000000
过了 10 个区块后,更新到:
1.000050
Alice 上次结算时的 supplierIndex:
1.000000
Alice 持有:
10,000 cDAI
那么:
deltaIndex = 1.000050 - 1.000000
= 0.000050
Alice COMP reward =
10,000 * 0.000050
= 0.5 COMP
然后:
compAccrued[Alice] += 0.5 COMP
compSupplierIndex[cDAI][Alice] = 1.000050
下次再结算时,只算新增加的 index 差值。
11. 借款侧奖励怎么分?
借款侧类似,但有一个额外问题:
借款人的债务会随 borrowIndex 增长。
奖励应该按用户当前借款占市场总借款的比例分配。
市场借款侧每区块分发:
borrowSpeed
从上次更新到现在:
borrower reward total = deltaBlocks * borrowSpeed
分母是:
totalBorrows
但源码里为了让单位和用户 borrow balance 对齐,会处理 marketBorrowIndex。
核心思想仍然是:
borrowRewardIndex += accruedComp / totalBorrows
用户奖励:
borrowerDelta =
borrowerBorrowBalance
* (globalBorrowRewardIndex - userBorrowerIndex)
12. updateCompBorrowIndex
借款侧更新函数大概叫:
updateCompBorrowIndex(address cToken, Exp memory marketBorrowIndex)
简化理解:
function updateCompBorrowIndex(address cToken, Exp memory marketBorrowIndex) internal {
CompMarketState memory borrowState = compBorrowState[cToken];
uint borrowSpeed = compBorrowSpeeds[cToken];
uint blockNumber = getBlockNumber();
uint deltaBlocks = blockNumber - borrowState.block;
if (deltaBlocks > 0 && borrowSpeed > 0) {
uint borrowAmount = CToken(cToken).totalBorrows();
uint compAccrued = deltaBlocks * borrowSpeed;
uint ratio = borrowAmount > 0
? compAccrued / borrowAmount
: 0;
borrowState.index = borrowState.index + ratio;
borrowState.block = blockNumber;
compBorrowState[cToken] = borrowState;
} else if (deltaBlocks > 0) {
borrowState.block = blockNumber;
compBorrowState[cToken] = borrowState;
}
}
真实源码会考虑:
marketBorrowIndex
borrowAmount / marketBorrowIndex
Double 精度
因为用户借款余额内部是通过 principal * borrowIndex / interestIndex 算出来的。奖励系统为了精度和一致性,会用 normalized borrow amount 做分母。
第一遍你先抓住:
借款侧全局 index 按每区块 borrowSpeed 和总借款规模增长。
13. distributeBorrowerComp
借款人结算函数大概是:
distributeBorrowerComp(
address cToken,
address borrower,
Exp memory marketBorrowIndex
)
简化逻辑:
function distributeBorrowerComp(address cToken, address borrower) internal {
CompMarketState memory borrowState = compBorrowState[cToken];
uint borrowIndex = borrowState.index;
uint borrowerIndex = compBorrowerIndex[cToken][borrower];
compBorrowerIndex[cToken][borrower] = borrowIndex;
if (borrowerIndex == 0 && borrowIndex >= compInitialIndex) {
borrowerIndex = compInitialIndex;
}
uint deltaIndex = borrowIndex - borrowerIndex;
uint borrowerAmount = CToken(cToken).borrowBalanceStored(borrower);
uint borrowerDelta = borrowerAmount * deltaIndex / 1e36;
compAccrued[borrower] += borrowerDelta;
}
核心公式:
borrowerDelta =
borrower borrow balance
* (globalBorrowRewardIndex - userBorrowerIndex)
它和供应侧非常像,只是用户余额换成了借款余额。
14. 借款侧例子
假设 cDAI borrow reward index 从:
2.000000
涨到:
2.000100
Alice 上次 borrowerIndex:
2.000000
Alice 当前 DAI 债务:
5,000 DAI
那么:
deltaIndex = 0.000100
Alice COMP reward =
5,000 * 0.000100
= 0.5 COMP
然后:
compAccrued[Alice] += 0.5 COMP
compBorrowerIndex[cDAI][Alice] = 2.000100
15. 为什么供应和借款都给 COMP?
这个设计有点反直觉:
供应者给协议流动性,奖励很好理解;
借款人为什么也奖励?
因为借款人也推动市场活跃度。
借款需求会带来:
资金利用率
供应者收益
协议收入
生态活跃度
如果只奖励供应,可能出现:
很多人存款,但没人借款
利用率低
供应收益低
协议没实际需求
同时奖励借款人,可以刺激需求。
当然,这也带来了著名的“流动性挖矿循环借贷”:
用户供应资产
借出资产
再供应
再借出
放大 COMP 激励
这就是 2020 DeFi summer 的经典玩法。快乐,也很泡沫味。
16. COMP 分发在哪些操作里触发?
奖励 index 不会每个区块自动更新。
它和利息一样,也是懒更新。
通常在这些动作中触发:
mint
redeem
borrow
repayBorrow
liquidateBorrow
transfer
claimComp
例如供应相关操作:
mint / redeem / transfer
-> updateCompSupplyIndex
-> distributeSupplierComp
借款相关操作:
borrow / repay / liquidate
-> updateCompBorrowIndex
-> distributeBorrowerComp
也就是说:
用户交互时,顺便把奖励结算到当前区块。
又是熟悉的懒更新。
17. mint 时如何分发 COMP?
用户 mint cToken 时,Comptroller 的 mintAllowed 或后续 hook 里可能会:
updateCompSupplyIndex(cToken)
distributeSupplierComp(cToken, minter)
为什么 mint 前要给 minter 结算?
因为用户 mint 后 cToken 余额会增加。
如果不先结算旧余额对应的奖励,用户可能用新余额吃到过去区块的奖励。
正确顺序是:
1. 先更新全局 supply index
2. 按用户 mint 前的 cToken 余额结算旧奖励
3. 再让 CToken 执行 mint,增加用户 cToken 余额
这样公平。
18. redeem 时如何分发 COMP?
redeem 前也要结算供应奖励:
updateCompSupplyIndex(cToken)
distributeSupplierComp(cToken, redeemer)
因为 redeem 后用户 cToken 余额会减少。
如果不先结算,用户过去持有的 cToken 奖励可能丢失。
正确顺序:
1. 更新全局 supply index
2. 按 redeem 前余额结算用户奖励
3. redeem 后余额减少
19. borrow 时如何分发 COMP?
borrow 会增加用户债务。
所以要先结算旧债务对应的借款奖励:
updateCompBorrowIndex(cToken, marketBorrowIndex)
distributeBorrowerComp(cToken, borrower, marketBorrowIndex)
然后 CToken 再更新:
accountBorrows[borrower].principal
totalBorrows
这样用户的新借款不会吃到过去区块的 borrow rewards。
20. repay 时如何分发 COMP?
repay 会减少用户债务。
所以也要先结算:
updateCompBorrowIndex(cToken, marketBorrowIndex)
distributeBorrowerComp(cToken, borrower, marketBorrowIndex)
然后再减少用户债务。
否则用户过去借款期间应得的奖励会丢。
21. transfer cToken 时如何分发 COMP?
cToken 是 ERC20,可以转账。
转账会改变供应侧余额:
src cToken balance 减少
dst cToken balance 增加
所以转账时要对双方结算供应奖励:
updateCompSupplyIndex(cToken)
distributeSupplierComp(cToken, src)
distributeSupplierComp(cToken, dst)
然后再转账。
否则可能出现:
接收方拿到过去本不属于他的奖励
转出方丢掉自己过去应得奖励
所以 reward index 的更新顺序非常重要。
22. claimComp
用户累计的奖励在:
compAccrued[user]
里,但不一定马上转给用户。
用户可以调用:
claimComp(address holder)
或者批量版本:
claimComp(address[] holders, CToken[] cTokens, bool borrowers, bool suppliers)
它会:
1. 对指定市场更新 supply / borrow index
2. 对用户分发供应侧 / 借款侧奖励到 compAccrued
3. 如果 compAccrued 足够且合约 COMP 余额足够,就 transfer COMP 给用户
4. compAccrued[user] 清零或减少
注意:
claimComp 不是凭空 mint COMP。
通常 Comptroller 需要持有足够 COMP,或者有对应的转账权限/分发机制。
23. grantComp 和 transferComp
源码里可能会看到类似:
grantComp(address recipient, uint amount)
transferComp(address user, uint userAccrued)
transferComp 通常会检查:
Comptroller 的 COMP 余额是否足够
如果足够:
转 COMP 给用户
compAccrued[user] = 0
如果不够,可能:
不转,保留 compAccrued
等以后有 COMP 再 claim
这样可以避免因为 COMP 余额不足导致用户奖励记录丢失。
24. compInitialIndex
源码里常见:
uint224 public constant compInitialIndex = 1e36;
为什么初始 index 不是 0,而是 1e36?
主要是为了区分:
用户从未初始化过 index
和:
用户 index 真的为 0
当市场开始分发奖励时,全局 index 通常初始化为 compInitialIndex。
用户第一次结算时,如果自己的 index 是 0,就设置成初始 index,避免把历史上不属于他的奖励一次性领走。
简化理解:
compInitialIndex 是奖励系统的基准起点。
25. 供应奖励和 borrowIndex 的类比
把 COMP supply index 和 cToken exchangeRate 类比:
供应者利息收益:
用户持有 cToken
exchangeRate 增长
可赎回 underlying 增加
供应者 COMP 奖励:
用户持有 cToken
compSupplyIndex 增长
compAccrued 增加
一个奖励是 underlying 利息,一个奖励是 COMP token。
26. 借款奖励和 borrowIndex 的类比
借款利息:
borrowIndex 增长
用户债务 = principal * borrowIndex / interestIndex
借款 COMP 奖励:
compBorrowState.index 增长
用户奖励 = borrowBalance * (globalCompBorrowIndex - userCompBorrowIndex)
都是:
全局 index 增长
用户保存上次 index
按差值结算
这就是 Compound v2 源码里非常统一的设计语言。
27. COMP 分发对真实借款成本的影响
如果借款人支付借款利息,但同时获得 COMP 奖励,那么真实借款成本可以变成:
netBorrowCost = borrowInterestCost - COMPRewardValue
在某些时期,可能出现:
借款利息 < COMP 奖励价值
也就是所谓:
负利率借款
用户借钱反而赚钱。
这会刺激循环借贷:
供应资产
借资产
再供应
再借
赚 COMP
这种玩法会提高协议 TVL 和 borrow volume,但也可能制造虚假需求和杠杆风险。
28. COMP 分发对供应收益的影响
供应者的总收益也变成:
totalSupplyYield =
underlyingSupplyInterest
+ COMPRewardValue
所以前端展示 APY 时,常会拆成:
Supply APY
Distribution APY
Net APY
源码层面:
Supply APY 来自 exchangeRate 增长
Distribution APY 来自 COMP 分发 index 增长
两套系统不同,但都影响用户收益。
29. COMP 分发的风险
COMP 激励不是免费午餐。
它可能带来:
循环借贷导致杠杆放大
短期 mercenary liquidity
激励结束后流动性撤离
治理代币抛压
市场参数被激励扭曲
借款需求不一定是真实需求
从源码角度看它很优雅;从经济角度看,它是一台很猛的增压器。增压器好不好,得看发动机和刹车。
30. COMP 分发源码骨架
整理一下:
Comptroller
|
|-- compSupplySpeeds[cToken]
|-- compBorrowSpeeds[cToken]
|
|-- compSupplyState[cToken]
| |-- index
| |-- block
|
|-- compBorrowState[cToken]
| |-- index
| |-- block
|
|-- compSupplierIndex[cToken][supplier]
|-- compBorrowerIndex[cToken][borrower]
|
|-- compAccrued[user]
|
|-- updateCompSupplyIndex(cToken)
|-- distributeSupplierComp(cToken, supplier)
|
|-- updateCompBorrowIndex(cToken, marketBorrowIndex)
|-- distributeBorrowerComp(cToken, borrower, marketBorrowIndex)
|
|-- claimComp(holder)
|-- transferComp(user, accrued)
31. 一次 mint 中的 COMP 奖励链路
Alice 调用 cDAI.mint(100)
|
v
cDAI.mintInternal
|
|-- accrueInterest()
|
v
mintFresh
|
|-- comptroller.mintAllowed(cDAI, Alice, 100)
|
|-- updateCompSupplyIndex(cDAI)
|-- distributeSupplierComp(cDAI, Alice)
|
|-- doTransferIn(Alice, 100)
|-- mint cDAI 给 Alice
重点:
先按 Alice 旧 cDAI 余额结算 COMP,
再增加 Alice cDAI 余额。
32. 一次 borrow 中的 COMP 奖励链路
Alice 调用 cDAI.borrow(100)
|
v
cDAI.borrowInternal
|
|-- accrueInterest()
|
v
borrowFresh
|
|-- comptroller.borrowAllowed(cDAI, Alice, 100)
|
|-- updateCompBorrowIndex(cDAI, marketBorrowIndex)
|-- distributeBorrowerComp(cDAI, Alice, marketBorrowIndex)
|
|-- 计算 Alice 当前债务
|-- accountBorrowsNew = oldDebt + 100
|-- 更新 Alice BorrowSnapshot
|-- 转出 DAI
重点:
先按 Alice 旧债务结算 COMP,
再增加新债务。
33. 一次 claimComp 链路
Alice 调用 claimComp(Alice)
|
v
Comptroller.claimComp
|
|-- 遍历相关 cToken 市场
|
|-- 对供应侧:
| updateCompSupplyIndex(cToken)
| distributeSupplierComp(cToken, Alice)
|
|-- 对借款侧:
| updateCompBorrowIndex(cToken, marketBorrowIndex)
| distributeBorrowerComp(cToken, Alice, marketBorrowIndex)
|
|-- accrued = compAccrued[Alice]
|
|-- transferComp(Alice, accrued)
|
|-- 如果 Comptroller COMP 余额足够:
| COMP.transfer(Alice, accrued)
| compAccrued[Alice] = 0
|
|-- 如果余额不够:
保留 compAccrued[Alice]
34. 这套机制最重要的公式
供应侧全局 index 增量:
supplyIndexDelta =
deltaBlocks * supplySpeed / totalSupply
供应者奖励:
supplierReward =
supplierCTokenBalance
* (supplyIndex - supplierIndex)
借款侧全局 index 增量:
borrowIndexDelta =
deltaBlocks * borrowSpeed / totalBorrows
借款人奖励:
borrowerReward =
borrowerBorrowBalance
* (borrowRewardIndex - borrowerIndex)
用户累计:
compAccrued[user] += reward
领取:
COMP.transfer(user, compAccrued[user])
实际源码里要加上 1e36 精度缩放。
35. 第十四讲要记住的 8 个结论
第一,COMP 分发奖励供应者和借款人。
第二,它不能遍历所有用户,所以也使用:
全局 index + 用户 index snapshot
第三,每个市场有供应侧 index:
compSupplyState[cToken].index
第四,每个市场有借款侧 index:
compBorrowState[cToken].index
第五,用户有自己的上次结算 index:
compSupplierIndex[cToken][user]
compBorrowerIndex[cToken][user]
第六,用户未领取奖励累计在:
compAccrued[user]
第七,供应奖励按 cToken 余额占比分,借款奖励按 borrow balance 占比分。
第八,COMP Flywheel 和 borrowIndex 是同一种设计思想:
不遍历用户;
全局 index 增长;
用户交互时按 index 差值结算。
下一讲回顾 安全设计与典型风险点总结。
我们会把 Compound v2 里所有关键安全边界串起来:
nonReentrant
Checks-Effects-Interactions
Comptroller allowed hooks
freshness check
cash check
oracle price check
borrowRate max check
closeFactor / liquidationIncentive
pauseGuardian
Timelock governance
proxy storage layout
non-standard ERC20 handling
这讲会像一次源码安全审计导览,把前面所有知识压成一张风险地图。