源码总复盘 + 一条完整交易 walkthrough
这讲不再介绍新模块,而是把前面所有东西串起来。我们用一个故事走完整个 Compound v2 生命周期:
Alice 存 ETH
Alice 把 cETH 设为抵押
Alice 借 DAI
市场计息
Alice 部分还款
ETH 价格下跌
Bob 清算 Alice
Bob 拿到 cETH
Bob redeem cETH
目标是:你看到一笔真实交易时,能知道它会落到哪些函数、改哪些状态、问哪些模块。
本文用一条完整交易链路复盘 Compound v2:Alice 存入 ETH、进入抵押市场、借出 DAI、经历计息和部分还款,随后因 ETH 价格下跌被 Bob 清算,最后 Bob 赎回拿到的 cETH。读完可以把 mint、enterMarkets、borrow、repayBorrow、liquidateBorrow、seize、redeem 串成一条完整执行路径,并理解每一步对应的状态变化、风控检查和价值换算。
我们设定三个市场:
cETH:
ETH 供应 / 抵押市场
cDAI:
DAI 借款市场
Comptroller:
全局风控中心
角色:
Alice:
存 ETH,借 DAI
Bob:
清算人
Comptroller:
判断 Alice 能不能借、能不能被清算
Oracle:
提供 ETH / DAI 价格
初始价格:
ETH price = 2000 USD
DAI price = 1 USD
cETH collateralFactor = 75%
liquidationIncentive = 1.08
closeFactor = 50%
Alice 调用:
cETH.mint{value: 10 ether}()
也就是供应 10 ETH。
CEther.mint()
-> mintInternal(msg.value)
-> accrueInterest()
-> mintFresh(Alice, 10 ETH)
1. Comptroller.mintAllowed(cETH, Alice, 10 ETH)
2. 检查 cETH 市场 fresh
3. 计算 exchangeRate
4. 接收 ETH
5. 计算 mintTokens
6. totalSupply 增加
7. accountTokens[Alice] 增加
8. emit Mint
9. emit Transfer(address(0), Alice, mintTokens)
假设:
cETH exchangeRate = 0.02 ETH / cETH
Alice 存 10 ETH,得到:
mintTokens = 10 / 0.02 = 500 cETH
操作后:
Alice accountTokens[cETH] = 500 cETH
cETH cash 增加 10 ETH
cETH totalSupply 增加 500 cETH
注意,这一步 Alice 只是供应了 ETH,还不一定能用它借款。
Alice 想用 cETH 作为抵押品,所以调用:
Comptroller.enterMarkets([cETH])
Comptroller.enterMarkets([cETH])
-> addToMarketInternal(cETH, Alice)
1. 检查 cETH market isListed
2. 检查 Alice 是否已经进入该市场
3. markets[cETH].accountMembership[Alice] = true
4. accountAssets[Alice].push(cETH)
5. emit MarketEntered(cETH, Alice)
操作后:
markets[cETH].accountMembership[Alice] = true
accountAssets[Alice] = [cETH]
这一步非常关键。
从现在开始,Comptroller 计算 Alice 账户健康度时,会把她的 cETH 算作抵押品。
Alice 调用:
cDAI.borrow(10000e18)
也就是借 10,000 DAI。
CErc20Delegator(cDAI)
-> delegatecall CErc20Delegate.borrow
-> borrowInternal(10000 DAI)
-> accrueInterest()
-> borrowFresh(Alice, 10000 DAI)
这里如果是代理版本,用户实际调用的是 cDAI Delegator,逻辑在 Delegate 中执行。
1. Comptroller.borrowAllowed(cDAI, Alice, 10000 DAI)
2. 检查 cDAI 市场 fresh
3. 检查 cDAI cash >= 10000 DAI
4. 计算 Alice 当前 DAI 债务
5. accountBorrowsNew = oldDebt + 10000
6. totalBorrowsNew = totalBorrows + 10000
7. 更新 Alice BorrowSnapshot
8. 更新 totalBorrows
9. doTransferOut(Alice, 10000 DAI)
10. emit Borrow
其中最关键的是:
Comptroller.borrowAllowed
Comptroller 会调用:
getHypotheticalAccountLiquidityInternal(
Alice,
cDAI,
redeemTokens = 0,
borrowAmount = 10000 DAI
)
意思是:
如果 Alice 现在新增 10,000 DAI 债务,
账户是否仍然健康?
Alice 抵押:
500 cETH
exchangeRate = 0.02 ETH / cETH
underlying ETH = 500 * 0.02 = 10 ETH
ETH 价格:
10 ETH * 2000 USD = 20,000 USD
抵押因子:
20,000 * 75% = 15,000 USD
新增借款:
10,000 DAI * 1 USD = 10,000 USD
所以:
liquidity = 15,000 - 10,000 = 5,000 USD
shortfall = 0
Alice 还能借,允许。
借款成功后:
cDAI cash:
减少 10,000 DAI
cDAI totalBorrows:
增加 10,000 DAI
Alice accountBorrows[cDAI]:
principal = 10,000 DAI
interestIndex = 当前 cDAI borrowIndex
Alice 钱包:
收到 10,000 DAI
注意,Alice 的 cETH 余额没变:
Alice 仍然持有 500 cETH
但这 500 cETH 正在支撑她的 DAI 债务,不能随便全部取走或转走。
过了一段时间,有人和 cDAI 市场交互,触发:
cDAI.accrueInterest()
accrueInterest()
-> get currentBlockNumber
-> get cashPrior
-> get totalBorrows
-> get totalReserves
-> interestRateModel.getBorrowRate(cash, borrows, reserves)
-> blockDelta = currentBlock - accrualBlockNumber
-> simpleInterestFactor = borrowRate * blockDelta
-> interestAccumulated = simpleInterestFactor * totalBorrows
-> totalBorrowsNew = totalBorrows + interestAccumulated
-> totalReservesNew = reserves + interestAccumulated * reserveFactor
-> borrowIndexNew = borrowIndex * (1 + simpleInterestFactor)
假设 Alice 借款后,市场产生了一些利息。
原来 Alice 快照:
principal = 10,000 DAI
interestIndex = 1.00
现在:
borrowIndex = 1.05
Alice 当前债务变成:
borrowBalance = 10,000 * 1.05 / 1.00
= 10,500 DAI
注意,Alice 的 accountBorrows.principal 可能还没被直接更新。
但通过 borrowIndex,她的当前债务已经能算出来。
Alice 调用:
cDAI.repayBorrow(2000e18)
也就是还 2,000 DAI。
cDAI.repayBorrow(2000)
-> repayBorrowInternal(2000)
-> accrueInterest()
-> repayBorrowFresh(
payer = Alice,
borrower = Alice,
repayAmount = 2000
)
1. Comptroller.repayBorrowAllowed(cDAI, Alice, Alice, 2000)
2. 检查 cDAI 市场 fresh
3. 计算 Alice 当前债务
4. repayAmountFinal = 2000
5. doTransferIn(Alice, 2000)
6. actualRepayAmount = 实际到账
7. accountBorrowsNew = accountBorrowsPrev - actualRepayAmount
8. totalBorrowsNew = totalBorrows - actualRepayAmount
9. 更新 Alice BorrowSnapshot
10. 更新 totalBorrows
11. emit RepayBorrow
假设还款前 Alice 当前债务:
10,500 DAI
还款:
2,000 DAI
还款后:
accountBorrowsNew = 10,500 - 2,000
= 8,500 DAI
新的快照:
principal = 8,500 DAI
interestIndex = 当前 borrowIndex
如果当前 borrowIndex 是 1.05,那么:
Alice accountBorrows[cDAI]:
principal = 8,500
interestIndex = 1.05
后续利息从这个新快照继续增长。
现在市场变化:
ETH price 从 2000 USD 跌到 1000 USD
Alice 抵押还是:
500 cETH
exchangeRate = 0.02 ETH / cETH
underlying = 10 ETH
新的抵押市值:
10 ETH * 1000 USD = 10,000 USD
乘抵押因子:
10,000 * 75% = 7,500 USD
Alice 当前 DAI 债务假设因为继续计息,已经变成:
8,800 DAI
借款价值:
8,800 USD
账户状态:
collateral value = 7,500 USD
borrow value = 8,800 USD
shortfall = 8,800 - 7,500
= 1,300 USD
Alice 账户不健康,可以被清算。
Bob 或清算机器人会调用:
Comptroller.getAccountLiquidity(Alice)
返回类似:
liquidity = 0
shortfall = 1,300 USD
这说明 Alice 可以被清算。
清算机器人还会计算:
最多能清算多少?
清算能拿多少 cETH?
扣掉 gas 和滑点是否赚钱?
Bob 调用:
cDAI.liquidateBorrow(
Alice,
4000e18,
cETH
)
意思是:
Bob 在 cDAI 市场替 Alice 还 4,000 DAI,
然后拿走 Alice 的 cETH 抵押品。
注意,调用发生在 借款市场 cDAI 上,而不是 cETH。
cDAI.liquidateBorrow(Alice, 4000, cETH)
-> liquidateBorrowInternal(Alice, 4000, cETH)
-> cDAI.accrueInterest()
-> cETH.accrueInterest()
-> liquidateBorrowFresh(Bob, Alice, 4000, cETH)
清算必须让两个市场都 fresh:
cDAI fresh:
Alice 当前债务准确
cETH fresh:
cETH exchangeRate 准确
liquidateBorrowFresh 第一件大事是:
Comptroller.liquidateBorrowAllowed(
cDAI,
cETH,
Bob,
Alice,
4000
)
Comptroller 会检查:
1. cDAI 市场 listed
2. cETH 市场 listed
3. 清算未暂停
4. Alice shortfall > 0
5. repayAmount <= closeFactor * Alice 当前 DAI 债务
6. Oracle 价格有效
假设 Alice 当前债务:
8,800 DAI
closeFactor:
50%
单次最多清算:
maxClose = 8,800 * 50%
= 4,400 DAI
Bob 想还:
4,000 DAI
满足:
4,000 <= 4,400
允许清算。
清算内部调用:
repayBorrowFresh(
payer = Bob,
borrower = Alice,
repayAmount = 4000
)
也就是:
Bob 付款
Alice 债务减少
流程:
1. doTransferIn(Bob, 4000 DAI)
2. actualRepayAmount = 4000 DAI
3. Alice accountBorrowsNew = 8,800 - 4,000 = 4,800 DAI
4. cDAI totalBorrows 减少 4,000 DAI
5. 更新 Alice BorrowSnapshot
此时 Alice 的 DAI 债务下降了。
但清算还没结束,Bob 还要拿抵押品。
调用:
Comptroller.liquidateCalculateSeizeTokens(
cDAI,
cETH,
actualRepayAmount
)
公式:
seizeTokens =
actualRepayAmount
* liquidationIncentive
* borrowedAssetPrice
/ collateralAssetPrice
/ collateralExchangeRate
参数:
actualRepayAmount = 4,000 DAI
liquidationIncentive = 1.08
DAI price = 1 USD
ETH price = 1000 USD
cETH exchangeRate = 0.02 ETH / cETH
先算 Bob 应得抵押品 underlying 价值:
4,000 * 1.08 = 4,320 USD
换成 ETH:
4,320 / 1000 = 4.32 ETH
换成 cETH:
4.32 / 0.02 = 216 cETH
所以:
seizeTokens = 216 cETH
清算调用:
cETH.seize(
Bob,
Alice,
216 cETH
)
进入:
cETH.seizeInternal(...)
-> Comptroller.seizeAllowed(...)
-> accountTokens[Alice] -= 216
-> accountTokens[Bob] += 216
-> emit Transfer(Alice, Bob, 216)
操作前:
Alice cETH = 500
Bob cETH = 0
操作后:
Alice cETH = 284
Bob cETH = 216
cETH totalSupply 通常不变,因为只是从 Alice 转给 Bob。
清算事件:
emit LiquidateBorrow(
liquidator = Bob,
borrower = Alice,
actualRepayAmount = 4000 DAI,
cTokenCollateral = cETH,
seizeTokens = 216 cETH
)
Alice 债务:
从 8,800 DAI 降到 4,800 DAI
Alice 抵押:
从 500 cETH 降到 284 cETH
对应 ETH:
284 * 0.02 = 5.68 ETH
当前抵押市值:
5.68 ETH * 1000 USD = 5,680 USD
可借抵押价值:
5,680 * 75% = 4,260 USD
Alice 借款价值:
4,800 USD
Alice 可能仍然有 shortfall:
4,800 - 4,260 = 540 USD
所以 Alice 还可能继续被清算。
这就是为什么 closeFactor 限制下,一个坏账账户可能需要多次清算。
Bob 此时拿到:
216 cETH
不是直接拿到 ETH。
这些 cETH 对应 underlying:
216 * 0.02 = 4.32 ETH
Bob 如果想拿 ETH,需要调用:
cETH.redeem(216 cETH)
或:
cETH.redeemUnderlying(4.32 ETH)
Bob 调用:
cETH.redeem(216)
cETH.redeem(216)
-> redeemInternal(216)
-> accrueInterest()
-> redeemFresh(Bob, redeemTokensIn = 216, redeemAmountIn = 0)
1. 检查 cETH fresh
2. 计算 exchangeRate
3. redeemAmount = 216 * 0.02 = 4.32 ETH
4. Comptroller.redeemAllowed(cETH, Bob, 216)
5. 检查 cETH cash >= 4.32 ETH
6. totalSupply -= 216
7. accountTokens[Bob] -= 216
8. doTransferOut(Bob, 4.32 ETH)
9. emit Redeem
10. emit Transfer(Bob, address(0), 216)
如果 Bob 没有借款,redeemAllowed 通常没问题。
但仍然要检查:
cETH cash >= 4.32 ETH
如果 cETH 市场现金不足,Bob 可能无法马上 redeem。
这就是清算人的流动性风险。
这一整套故事里,所有核心模块都出现了:
CEther / CErc20:
用户入口
CToken:
mint / redeem / borrow / repay / liquidate 核心执行
Comptroller:
enterMarkets
borrowAllowed
redeemAllowed
liquidateBorrowAllowed
seizeAllowed
getAccountLiquidity
InterestRateModel:
accrueInterest 中计算 borrowRate
PriceOracle:
计算 ETH / DAI 价值
判断 shortfall
计算 seizeTokens
ExponentialNoError:
exchangeRate
collateralFactor
liquidationIncentive
rate
index
Proxy:
cDAI Delegator / Delegate
Unitroller / Comptroller implementation
Governance:
管理 collateralFactor、closeFactor、oracle、interestRateModel 等参数
COMP Flywheel:
mint / borrow / repay / transfer 时可能顺便更新 COMP 奖励
你看,Compound v2 的一次“借贷 + 清算”不是单个函数,而是一组模块协作。
Alice mint ETH
|
v
cETH.mint
|
|-- accrueInterest
|-- Comptroller.mintAllowed
|-- mint cETH to Alice
|
v
Alice enterMarkets([cETH])
|
v
Comptroller
|
|-- markets[cETH].accountMembership[Alice] = true
|-- accountAssets[Alice].push(cETH)
|
v
Alice borrow DAI
|
v
cDAI.borrow
|
|-- accrueInterest
|-- Comptroller.borrowAllowed
| |-- Oracle ETH price
| |-- Oracle DAI price
| |-- cETH exchangeRate
| |-- collateralFactor
| |-- getHypotheticalAccountLiquidity
|
|-- cash check
|-- update Alice BorrowSnapshot
|-- transfer DAI to Alice
|
v
ETH price falls
|
v
Comptroller.getAccountLiquidity(Alice)
|
|-- shortfall > 0
|
v
Bob liquidates
|
v
cDAI.liquidateBorrow(Alice, repayAmount, cETH)
|
|-- cDAI.accrueInterest
|-- cETH.accrueInterest
|-- Comptroller.liquidateBorrowAllowed
| |-- shortfall check
| |-- closeFactor check
|
|-- repayBorrowFresh(Bob, Alice, repayAmount)
|-- Comptroller.liquidateCalculateSeizeTokens
| |-- DAI price
| |-- ETH price
| |-- liquidationIncentive
| |-- cETH exchangeRate
|
|-- cETH.seize(Bob, Alice, seizeTokens)
|
v
Bob gets cETH
|
v
Bob redeem cETH
|
|-- cETH.accrueInterest
|-- Comptroller.redeemAllowed
|-- cash check
|-- burn Bob cETH
|-- transfer ETH to Bob
这张图就是 Compound v2 的核心运行闭环。
cETH.cash ↑
cETH.totalSupply ↑
cETH.accountTokens[Alice] ↑
Comptroller.markets[cETH].accountMembership[Alice] = true
Comptroller.accountAssets[Alice].push(cETH)
cDAI.cash ↓
cDAI.totalBorrows ↑
cDAI.accountBorrows[Alice].principal ↑
cDAI.accountBorrows[Alice].interestIndex = current borrowIndex
Alice DAI wallet balance ↑
cDAI.totalBorrows ↑
cDAI.totalReserves ↑
cDAI.borrowIndex ↑
cDAI.accrualBlockNumber = current block
cDAI.cash ↑
cDAI.totalBorrows ↓
cDAI.accountBorrows[Alice].principal ↓
cDAI.accountBorrows[Alice].interestIndex = current borrowIndex
Alice DAI wallet balance ↓
No CToken storage necessarily changes
Oracle price changes
Comptroller account liquidity result changes
这一点非常关键:
价格变动可能不改用户仓位,
但会改变用户是否可被清算。
借款市场 cDAI:
cDAI.cash ↑
cDAI.totalBorrows ↓
cDAI.accountBorrows[Alice].principal ↓
cDAI.accountBorrows[Alice].interestIndex = current borrowIndex
Bob DAI wallet balance ↓
抵押市场 cETH:
cETH.accountTokens[Alice] ↓
cETH.accountTokens[Bob] ↑
cETH.totalSupply 通常不变
cETH.cash ↓
cETH.totalSupply ↓
cETH.accountTokens[Bob] ↓
Bob ETH balance ↑
这些操作会触发事件:
Alice mint:
Mint
Transfer(address(0), Alice, mintTokens)
Alice borrow:
Borrow
Alice repay:
RepayBorrow
Bob liquidate:
RepayBorrow
Transfer(Alice, Bob, seizeTokens)
LiquidateBorrow
Bob redeem:
Transfer(Bob, address(0), redeemTokens)
Redeem
看真实链上交易时,事件是非常好的线索。
比如清算交易里你通常会看到:
RepayBorrow
Transfer collateral cToken from borrower to liquidator
LiquidateBorrow
如果清算人随后 redeem,还会看到:
Redeem
Transfer cToken to address(0)
整个 Compound v2 可以压缩成三个换算。
underlying <-> cToken
公式:
mintTokens = mintAmount / exchangeRate
redeemAmount = redeemTokens * exchangeRate
cToken -> underlying -> USD-ish value -> collateral value
公式:
collateralValue =
cTokenBalance
* exchangeRate
* oraclePrice
* collateralFactor
借款价值:
borrowValue =
borrowBalance
* oraclePrice
repaid borrowed asset -> seized collateral cToken
公式:
seizeTokens =
actualRepayAmount
* liquidationIncentive
* borrowedAssetPrice
/ collateralAssetPrice
/ collateralExchangeRate
把这三个换算吃透,Compound v2 80% 的核心逻辑就稳了。
Compound v2 里有三个非常重要的 index 思想。
用于借款利息:
borrowBalance =
principal * currentBorrowIndex / snapshotInterestIndex
用于供应者收益:
underlyingBalance =
cTokenBalance * exchangeRate
用于 COMP 分发:
supplierReward =
supplierBalance * (globalSupplyIndex - supplierIndex)
borrowerReward =
borrowBalance * (globalBorrowRewardIndex - borrowerIndex)
它们的共同思想是:
不遍历所有用户;
维护一个全局增长指标;
用户交互时按快照差值结算。
这是 Compound v2 最漂亮的设计模式之一。
因为 Compound 避免了链上最贵的东西:
遍历所有用户
它没有:
for each supplier:
update supplier interest
for each borrower:
update borrower debt
for each comp recipient:
distribute COMP
而是用:
global index + user snapshot
所以无论有 100 个用户还是 100 万个用户,单次用户操作主要只处理当前用户和当前市场。
这就是 DeFi 协议可扩展性的核心技巧。
以后你看链上 borrow 交易,可以按这个顺序解读:
1. 是哪个 cToken 市场?
例如 cDAI
2. 借了多少 underlying?
borrowAmount
3. 交易前有没有 AccrueInterest?
看 AccrueInterest 事件或状态变化
4. Comptroller 是否允许?
如果成功,说明 borrowAllowed 过了
5. 账户抵押是什么?
查 accountAssets[user]
6. 借款后 accountBorrows 怎么变?
principal / interestIndex
7. totalBorrows 怎么变?
totalBorrows += borrowAmount
8. cash 怎么变?
cash -= borrowAmount
9. 事件 Borrow 里的 accountBorrowsNew 是多少?
如果 borrow 失败,常见原因:
账户抵押不足
市场 cash 不足
价格无效
borrow paused
市场未 listed
accrueInterest 失败
清算交易更复杂,按这个顺序:
1. borrowed market 是哪个?
调用哪个 cToken 的 liquidateBorrow
2. collateral market 是哪个?
参数 cTokenCollateral
3. borrower 是谁?
被清算账户
4. liquidator 是谁?
msg.sender
5. repayAmount 是多少?
清算人想还多少
6. actualRepayAmount 是多少?
看 RepayBorrow 事件
7. seizeTokens 是多少?
看 LiquidateBorrow 事件
8. borrower collateral cToken 是否减少?
看 Transfer(borrower, liquidator, seizeTokens)
9. 是否随后 redeem?
看 liquidator 是否调用 collateral cToken redeem
10. 清算是否有利润?
比较 repay 成本、seized collateral 价值、gas、滑点
清算失败常见原因:
borrower 还没有 shortfall
repayAmount 超过 closeFactor
repayAmount 为 0 或 uint(-1)
价格无效
allowance 不够
清算市场 paused
抵押市场不 fresh
借款市场不 fresh
到现在,我们已经把主要模块都走过了:
1. 总架构
2. mint / supply
3. accrueInterest
4. borrow
5. repayBorrow
6. redeem / withdraw
7. liquidateBorrow
8. Comptroller
9. InterestRateModel
10. PriceOracle
11. ExponentialNoError
12. Proxy / Upgrade
13. Governance
14. COMP Flywheel
15. 安全设计
16. 完整交易 walkthrough
这已经是一套相当完整的 Compound v2 源码学习路径。
下一阶段可以从“读懂源码”进入“实战验证”。
我建议后面按这几个方向继续:
方向一:搭本地测试环境
fork mainnet
部署 mock token / mock oracle
跑 mint / borrow / liquidate 测试
方向二:逐函数对照真实源码
打开 CToken.sol
逐行读 mintFresh / borrowFresh / liquidateBorrowFresh
方向三:读测试文件
Compound v2 的测试比注释更像说明书
方向四:做一笔主网交易解析
找一笔真实 liquidation
用事件和状态变化还原全过程
方向五:审计一个 Compound fork
检查 oracle、decimals、market listing、storage layout、admin 权限
第一,一次完整借贷生命周期会跨越 CToken、Comptroller、Oracle、InterestRateModel、Math、Proxy 等多个模块。
第二,供应 ETH 后,只有调用 enterMarkets,cETH 才会作为抵押品进入账户健康度计算。
第三,borrow 的核心是:
先计息
再问 Comptroller
再检查 cash
再更新 BorrowSnapshot
最后转出 underlying
第四,价格变化本身不一定改变用户仓位,但会改变账户 liquidity / shortfall。
第五,清算本质是:
repayBorrowFresh + liquidateCalculateSeizeTokens + seize
第六,清算人拿到的是抵押品 cToken,不一定能立刻 redeem underlying。
第七,Compound v2 的核心模式是:
全局 index + 用户 snapshot
第八,真实交易分析要从事件、状态变量、Comptroller 检查和 Oracle 价格四个角度一起看。
到这里,Compound v2 的主线已经完整闭环了。后续最值得做的是:拿一份真实源码,从 CToken.sol 的 mintFresh 开始逐行对照你已经学过的模型读。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码