五、从源码讲解compound v2 repayBorrow 源码精读

tomenengr 发布于 2026-05-05 阅读 140

repayBorrow 源码精读

前面 Borrow 讲的是:

用户从市场拿走 underlying
用户债务增加
totalBorrows 增加

Repay 正好反过来:

用户把 underlying 还给市场
用户债务减少
totalBorrows 减少

但 Repay 不是简单 debt -= amount。它仍然要经过:

accrueInterest
Comptroller 校验
当前债务计算
actualRepayAmount 计算
BorrowSnapshot 更新
totalBorrows 更新

第五讲:repayBorrow 源码精读

简介

本章精读 Compound v2 的 Repay Borrow 主线,梳理 repayBorrowrepayBorrowBehalf 两种还款入口如何进入 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 的影响

相关文章

0 条评论