十四、从源码讲解compound v2 COMP 分发机制 / Flywheel 激励系统

tomenengr 发布于 2026-05-06 阅读 231

COMP 分发机制 / Flywheel 激励系统

这讲很有意思,因为它和前面的 borrowIndex 思想非常像:

borrowIndex:
  不遍历所有借款人,也能让债务随时间增长

COMP reward index:
  不遍历所有供应者/借款人,也能给用户累计 COMP 奖励

Compound v2 里这套奖励系统也常被叫做 Flywheel,飞轮机制。


第十四讲:COMP 分发机制 / Flywheel

简介

本文精读 Compound v2 的 COMP 分发机制,也就是 Flywheel 激励系统,梳理协议如何通过 compSupplyStatecompBorrowState、用户 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.indexCToken.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 会放大,比如用 Double1e36 精度:

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

这讲会像一次源码安全审计导览,把前面所有知识压成一张风险地图。

相关文章

0 条评论