五、从源码讲解compound v2 repayBorrow 源码精读
repayBorrow 源码精读
前面 Borrow 讲的是:
用户从市场拿走 underlying
用户债务增加
totalBorrows 增加
Repay 正好反过来:
用户把 underlying 还给市场
用户债务减少
totalBorrows 减少
但 Repay 不是简单 debt -= amount。它仍然要经过:
accrueInterest
Comptroller 校验
当前债务计算
actualRepayAmount 计算
BorrowSnapshot 更新
totalBorrows 更新
第五讲:repayBorrow 源码精读
简介
本章精读 Compound v2 的 Repay Borrow 主线,梳理 repayBorrow 和 repayBorrowBehalf 两种还款入口如何进入 repayBorrowFresh,并完成计息、风控校验、当前债务计算、实际到账金额确认和借款快照更新。读完可以理解为什么还款前必须先 accrueInterest(),以及 uint(-1) 全额还款和 actualRepayAmount 的关键作用。
1. Repay 有两种入口
Compound v2 里还款常见有两个入口:
repayBorrow(uint repayAmount)
repayBorrowBehalf(address borrower, uint repayAmount)
区别是:
repayBorrow:
给自己还款
repayBorrowBehalf:
替别人还款
比如:
Alice 欠 100 DAI
Alice 调用 repayBorrow(100) -> 自己还
Bob 调用 repayBorrowBehalf(Alice, 100) -> Bob 替 Alice 还
清算时其实也会用到类似逻辑:清算人替坏账用户还一部分债,然后拿走抵押品。
2. ERC20 市场入口
在 CErc20.sol 里,入口大概是:
function repayBorrow(uint repayAmount) external returns (uint) {
repayBorrowInternal(repayAmount);
return NO_ERROR;
}
function repayBorrowBehalf(
address borrower,
uint repayAmount
) external returns (uint) {
repayBorrowBehalfInternal(borrower, repayAmount);
return NO_ERROR;
}
你会发现:入口仍然很薄。
真正逻辑在 CToken.sol。
3. repayBorrowInternal:给自己还款
结构类似:
function repayBorrowInternal(uint repayAmount) internal nonReentrant {
accrueInterest();
repayBorrowFresh(msg.sender, msg.sender, repayAmount);
}
参数注意一下:
payer = msg.sender
borrower = msg.sender
也就是说:
谁付款?msg.sender
还谁的债?msg.sender
4. repayBorrowBehalfInternal:替别人还款
结构是:
function repayBorrowBehalfInternal(
address borrower,
uint repayAmount
) internal nonReentrant {
accrueInterest();
repayBorrowFresh(msg.sender, borrower, repayAmount);
}
这次:
payer = msg.sender
borrower = borrower
意思是:
payer 负责付钱
borrower 的债务减少
举个例子:
Bob 调用 repayBorrowBehalf(Alice, 100)
payer = Bob
borrower = Alice
所以后面 doTransferIn 是从 Bob 钱包转钱,
但 accountBorrows 更新的是 Alice。
5. 为什么 repay 前也要 accrueInterest?
因为还款必须基于 当前债务,不是旧快照债务。
比如 Alice 快照里:
principal = 1000 DAI
interestIndex = 1.00
现在市场:
borrowIndex = 1.05
Alice 当前债务是:
1000 * 1.05 / 1.00 = 1050 DAI
如果不先计息,她可能只还 1000 就被当作还清。 这会把中间利息赖掉,供应者直接破防。
所以必须:
先 accrueInterest()
再计算 borrower 当前债务
再扣除 repayAmount
6. repayBorrowFresh 的整体结构
简化骨架:
function repayBorrowFresh(
address payer,
address borrower,
uint repayAmount
) internal returns (uint) {
// 1. 问 Comptroller 是否允许还款
comptroller.repayBorrowAllowed(
address(this),
payer,
borrower,
repayAmount
);
// 2. 确认市场 fresh
require(accrualBlockNumber == getBlockNumber());
// 3. 计算 borrower 当前债务
uint accountBorrowsPrev =
borrowBalanceStoredInternal(borrower);
// 4. 如果 repayAmount 是最大值,则还清全部
uint repayAmountFinal =
repayAmount == type(uint).max
? accountBorrowsPrev
: repayAmount;
// 5. 从 payer 转入 underlying,拿到实际到账
uint actualRepayAmount =
doTransferIn(payer, repayAmountFinal);
// 6. 新债务 = 当前债务 - 实际还款
uint accountBorrowsNew =
accountBorrowsPrev - actualRepayAmount;
// 7. 新 totalBorrows = 旧 totalBorrows - 实际还款
uint totalBorrowsNew =
totalBorrows - actualRepayAmount;
// 8. 更新 borrower 借款快照
accountBorrows[borrower].principal = accountBorrowsNew;
accountBorrows[borrower].interestIndex = borrowIndex;
// 9. 更新 totalBorrows
totalBorrows = totalBorrowsNew;
// 10. 触发事件
emit RepayBorrow(
payer,
borrower,
actualRepayAmount,
accountBorrowsNew,
totalBorrowsNew
);
return actualRepayAmount;
}
主线很清楚:
算当前债务
确定实际还款金额
转入 underlying
减少 borrower 债务
减少 totalBorrows
7. 第一步:Comptroller.repayBorrowAllowed
uint allowed = comptroller.repayBorrowAllowed(
address(this),
payer,
borrower,
repayAmount
);
你可能会问:
还款不是好事吗?为什么还要 allowed?
确实,大多数情况下还款被允许。 但 Compound 仍然把所有操作都统一接入 Comptroller。
这样协议治理可以:
暂停某些市场
统一做权限钩子
在特殊情况下阻止异常操作
保持 CToken 和 Comptroller 的架构一致
设计上保持统一:
所有用户动作都先问 Comptroller。
这让协议风控可以集中在 Comptroller,不散落在每个 CToken 里。
8. 第二步:Freshness Check
和 mint / borrow 一样:
if (accrualBlockNumber != getBlockNumber()) {
revert RepayBorrowFreshnessCheck();
}
意思:
市场必须已经计息到当前区块。
repayBorrowInternal 已经调用了 accrueInterest(),这里再次确认。
因为 repayBorrowFresh 的语义是:
我只处理 fresh 状态下的还款。
9. 第三步:计算 borrower 当前债务
uint accountBorrowsPrev =
borrowBalanceStoredInternal(borrower);
这个函数会用:
principal * borrowIndex / interestIndex
计算当前债务。
比如:
principal = 1000
interestIndex = 1.00
borrowIndex = 1.05
当前债务:
accountBorrowsPrev = 1050
注意:这里算的是 borrower 的债务,不一定是 payer 的债务。
如果是 repayBorrowBehalf:
payer = Bob
borrower = Alice
那这里算的是 Alice 欠多少钱。
10. 第四步:repayAmount = uint(-1) 的特殊含义
Compound v2 里有一个经典约定:
repayAmount == uint(-1)
表示还清全部债务
在新 Solidity 里可以理解成:
type(uint).max
源码逻辑类似:
uint repayAmountFinal =
repayAmount == uint(-1)
? accountBorrowsPrev
: repayAmount;
为什么要这样设计?
因为用户前端很难精确知道当前区块的债务。
债务会随着 borrowIndex 增长,而且交易打包到哪个区块也不确定。
如果用户想“全额还款”,传一个具体数字可能会:
少还一点点,因为打包时又产生了新利息
所以协议提供了一个特殊值:
传最大 uint,表示按当前计算出的债务全额还。
这在 DeFi 里很常见。粗暴但好用,有点 Solidity 早期土味智慧。
11. 第五步:doTransferIn
确定要还多少后,从 payer 转入 underlying:
uint actualRepayAmount =
doTransferIn(payer, repayAmountFinal);
为什么用 actualRepayAmount?
和 mint 一样,Compound 不盲信传入金额。
因为 ERC20 可能:
不标准返回
有手续费
实际到账金额小于请求金额
transferFrom 行为异常
所以要按实际到账金额计算还款。
如果 payer 传入 100,但合约只收到 99, 那 borrower 的债务只能减少 99。
否则系统就会亏。
12. 第六步:新债务 = 当前债务 - 实际还款
uint accountBorrowsNew =
accountBorrowsPrev - actualRepayAmount;
比如:
Alice 当前债务 = 1050 DAI
实际还款 = 200 DAI
Alice 新债务 = 850 DAI
如果是全额还款:
actualRepayAmount = accountBorrowsPrev
accountBorrowsNew = 0
这里要注意一个安全点:
actualRepayAmount 不能大于 accountBorrowsPrev
因为那会导致下溢。真实源码会通过前面的 repayAmountFinal 和安全数学处理。
13. 第七步:totalBorrows 减少
uint totalBorrowsNew =
totalBorrows - actualRepayAmount;
因为用户还款后,市场总债权减少。
例如:
totalBorrows = 1,000,000
actualRepayAmount = 200
totalBorrowsNew = 999,800
注意:这里减少的是 actualRepayAmount,不是用户传入的 repayAmount。
还是那句话:
链上只认实际到账。
14. 第八步:更新 BorrowSnapshot
accountBorrows[borrower].principal = accountBorrowsNew;
accountBorrows[borrower].interestIndex = borrowIndex;
这跟 borrow 是同一个思路。
还款后,把 borrower 的新债务固化成当前 borrowIndex 下的快照。
比如还款前:
principal = 1000
interestIndex = 1.00
borrowIndex = 1.05
当前债务:
1050
还款 200 后:
accountBorrowsNew = 850
更新快照:
principal = 850
interestIndex = 1.05
以后继续按照新的 borrowIndex 增长:
futureDebt = 850 * futureBorrowIndex / 1.05
15. 全额还款后的快照
如果 Alice 全额还清:
accountBorrowsNew = 0
源码仍然会设置:
principal = 0
interestIndex = borrowIndex
此后计算债务:
0 * borrowIndex / interestIndex = 0
所以债务归零。
16. 事件 RepayBorrow
还款完成后触发:
emit RepayBorrow(
payer,
borrower,
actualRepayAmount,
accountBorrowsNew,
totalBorrowsNew
);
字段含义:
payer:
实际付款的人
borrower:
债务被减少的人
actualRepayAmount:
实际还款金额
accountBorrowsNew:
borrower 还款后的债务
totalBorrowsNew:
市场还款后的总借款
如果是自己还款:
payer == borrower
如果是代还:
payer != borrower
17. repayBorrowBehalf 的典型用途
repayBorrowBehalf 看起来像个小功能,但用途不少。
场景一:第三方帮用户还款
比如协议、钱包、亲友、自动化机器人帮用户还债。
Bob 替 Alice 还 100 DAI
Bob 付 DAI,Alice 债务减少。
场景二:清算
清算本质上也是:
清算人替坏账用户还一部分债务
然后拿走坏账用户的一部分抵押品
虽然清算走的是 liquidateBorrow,但内部还款逻辑和 repayBorrowFresh 强相关。
场景三:协议集成
其他 DeFi 协议可以帮用户做:
自动还款
债务迁移
杠杆调整
再融资
18. Repay 和 Borrow 的镜像关系
Borrow:
accountBorrowsPrev = 当前债务
accountBorrowsNew = accountBorrowsPrev + borrowAmount
totalBorrowsNew = totalBorrows + borrowAmount
doTransferOut(borrower, borrowAmount)
Repay:
accountBorrowsPrev = 当前债务
accountBorrowsNew = accountBorrowsPrev - actualRepayAmount
totalBorrowsNew = totalBorrows - actualRepayAmount
doTransferIn(payer, actualRepayAmount)
很对称:
Borrow:
市场 -> 用户
债务增加
Repay:
用户 -> 市场
债务减少
19. Repay 对市场状态的影响
一次 repay 后:
cash:
增加 actualRepayAmount
totalBorrows:
减少 actualRepayAmount
accountBorrows[borrower]:
principal 减少
interestIndex 更新为当前 borrowIndex
totalSupply:
不变
accountTokens:
不变
exchangeRate:
理论上不因普通还款变化
为什么普通还款理论上不改变 exchangeRate?
还款时:
cash + actualRepayAmount
totalBorrows - actualRepayAmount
代入:
exchangeRate = (cash + totalBorrows - reserves) / totalSupply
一加一减,抵消。
所以还款本身不产生收益。
收益已经在 accrueInterest() 里通过新增利息体现了。
和 borrow 一样:
普通 borrow / repay 只是改变 cash 与 borrows 的组成,
不会改变总资产。
真正改变总资产的是利息、坏账、储备金等。
20. 用完整例子走一遍 repay
假设 DAI 市场:
borrowIndex = 1.05
totalBorrows = 1,000,000 DAI
accrualBlockNumber = 当前区块
Alice 的快照:
principal = 1000 DAI
interestIndex = 1.00
Alice 想还:
200 DAI
流程:
1. Alice 调用 cDAI.repayBorrow(200)
2. repayBorrowInternal 调用 accrueInterest()
3. repayBorrowFresh(Alice, Alice, 200)
4. Comptroller.repayBorrowAllowed(...)
5. fresh check
6. 计算 Alice 当前债务:
1000 * 1.05 / 1.00 = 1050
7. repayAmountFinal = 200
8. doTransferIn(Alice, 200)
9. actualRepayAmount = 200
10. accountBorrowsNew = 1050 - 200 = 850
11. totalBorrowsNew = 1,000,000 - 200 = 999,800
12. 更新 Alice 快照:
principal = 850
interestIndex = 1.05
13. 更新 totalBorrows
14. emit RepayBorrow
还款后:
Alice 不是欠 800,
而是欠 850。
因为还款前她的旧债务已经从 1000 长到 1050 了。
21. 全额还款例子
Alice 快照:
principal = 1000
interestIndex = 1.00
当前:
borrowIndex = 1.05
Alice 当前债务:
1050
如果她想全额还款,可以传:
repayAmount = uint(-1)
源码会把它转成:
repayAmountFinal = accountBorrowsPrev = 1050
然后:
actualRepayAmount = 1050
accountBorrowsNew = 0
快照:
principal = 0
interestIndex = 当前 borrowIndex
债务清零。
22. 如果是 Bob 替 Alice 还款
Alice 当前债务:
1050 DAI
Bob 调用:
cDAI.repayBorrowBehalf(Alice, 200)
流程里:
payer = Bob
borrower = Alice
所以:
doTransferIn(Bob, 200)
accountBorrows[Alice] 减少 200
事件:
RepayBorrow(
payer = Bob,
borrower = Alice,
actualRepayAmount = 200,
accountBorrowsNew = 850,
totalBorrowsNew = ...
)
Bob 付钱,Alice 少欠债。
这就是代还。
23. Repay 为什么没有 cash 不足检查?
Borrow 需要检查:
cash >= borrowAmount
因为合约要往外转钱。
Repay 不需要检查市场现金,因为用户是把钱转进来。
Repay 要关注的是:
payer 是否 approve 了足够额度
payer 是否有足够 underlying
token transferFrom 是否成功
实际到账多少
这些都在 doTransferIn 里处理。
24. Repay 与 allowance
对于 ERC20 市场,用户还款前需要先授权:
underlying.approve(cTokenAddress, amount);
然后再调用:
cToken.repayBorrow(amount);
因为 repayBorrow 内部要从用户那里拉走 underlying:
transferFrom(payer, cToken, repayAmount)
如果 allowance 不够,doTransferIn 会失败。
ETH 市场不同,CEther.repayBorrow 通常是 payable,用户直接发送 ETH。
25. CEther 的 repay 区别
对于 ETH 市场:
CEther.repayBorrow() payable
用户还 ETH 时不需要 approve。
流程大概是:
function repayBorrow() external payable {
repayBorrowInternal(msg.value);
}
代还:
function repayBorrowBehalf(address borrower) external payable {
repayBorrowBehalfInternal(borrower, msg.value);
}
核心逻辑还是一样:
payer 付款
borrower 债务减少
accountBorrows 更新
totalBorrows 减少
只是 ETH 的付款来自 msg.value,不是 transferFrom。
26. Repay 中的安全顺序
Repay 的顺序大概是:
Checks:
Comptroller.repayBorrowAllowed
fresh check
计算当前债务
Interactions:
doTransferIn
Effects:
更新 accountBorrows
更新 totalBorrows
你可能会发现,这和 Borrow 的 Checks-Effects-Interactions 有点不一样。
Repay 是先 doTransferIn,再更新状态。
为什么?
因为还款要以实际到账金额为准。
如果先更新债务,再转账,遇到手续费 token 或实际到账不足,就会产生错账。
所以 Repay 必须:
先把钱收进来
确认 actualRepayAmount
再减少债务
这里靠 nonReentrant 和谨慎的 transfer 封装来控制风险。
27. Repay 最容易踩的点
坑一:以为 repayAmount 就是实际还款
不是。
真实减少债务的是:
actualRepayAmount
也就是实际到账金额。
坑二:以为还 1000 就能还清 1000 principal
不一定。
如果这段时间有利息,当前债务可能已经变成 1050。
坑三:忘记 approve
ERC20 还款前要 approve。
坑四:替别人还款会扣自己的钱
repayBorrowBehalf 是:
payer = msg.sender
borrower = 参数 borrower
不要把这两个角色搞混。
坑五:还款本身不会给供应者新增收益
收益来自计息。
还款只是把资产从 totalBorrows 变回 cash。
28. Repay 源码骨架最终版
CErc20.repayBorrow(repayAmount)
|
v
CToken.repayBorrowInternal(repayAmount)
|
|-- nonReentrant
|
|-- accrueInterest()
|
v
repayBorrowFresh(payer, borrower, repayAmount)
|
|-- comptroller.repayBorrowAllowed()
|
|-- require market is fresh
|
|-- accountBorrowsPrev = borrowBalanceStoredInternal(borrower)
|
|-- repayAmountFinal =
| repayAmount == uint(-1)
| ? accountBorrowsPrev
| : repayAmount
|
|-- actualRepayAmount = doTransferIn(payer, repayAmountFinal)
|
|-- accountBorrowsNew =
| accountBorrowsPrev - actualRepayAmount
|
|-- totalBorrowsNew =
| totalBorrows - actualRepayAmount
|
|-- accountBorrows[borrower].principal = accountBorrowsNew
|
|-- accountBorrows[borrower].interestIndex = borrowIndex
|
|-- totalBorrows = totalBorrowsNew
|
|-- emit RepayBorrow
29. Borrow / Repay 生命周期合起来看
假设 Alice 第一次借款:
borrowIndex = 1.00
Alice borrow 1000
快照:
principal = 1000
interestIndex = 1.00
一段时间后:
borrowIndex = 1.05
Alice 当前债务:
1000 * 1.05 / 1.00 = 1050
Alice 还款 200:
newPrincipal = 1050 - 200 = 850
newInterestIndex = 1.05
再过一段时间:
borrowIndex = 1.10
Alice 当前债务:
850 * 1.10 / 1.05 ≈ 890.48
这个例子把借款快照模型讲透了:
每次 borrow / repay 都会把当前债务重新记录成 principal,
然后从当前 borrowIndex 继续增长。
30. 第五讲要记住的 6 个结论
第一,repayBorrow 是自己还款,repayBorrowBehalf 是替别人还款。
第二,repay 前必须 accrueInterest(),因为要基于当前债务还款。
第三,用户当前债务仍然是:
principal * borrowIndex / interestIndex
第四,uint(-1) 表示全额还款。
第五,真正减少债务的是 actualRepayAmount,也就是实际到账金额。
第六,还款后会更新:
accountBorrows[borrower].principal
accountBorrows[borrower].interestIndex
totalBorrows
下一讲我们读 Redeem / Withdraw 源码。
它会回答一个非常关键的问题:
用户取回自己的钱,为什么还要被 Comptroller 拦?
因为在借贷协议里,用户存入的资产可能已经被当成抵押品。 你一取,账户健康度可能直接变红。
下一讲重点:
redeem vs redeemUnderlying 的区别
redeemTokens 和 redeemAmount 怎么互相换算
为什么 redeem 前也要 accrueInterest
Comptroller.redeemAllowed 怎么防止用户抽走抵押
为什么要检查 cash 是否足够
redeem 对 exchangeRate 的影响