二、从源码讲解compound v2 mint源码精读

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

本文精读 Compound v2 的 Supply / Mint 主线,梳理用户调用 CErc20.mint 后,资金如何进入 CToken.mintInternal、先执行 accrueInterest(),再通过 mintFresh 完成风控检查、兑换率计算、转账和 cToken 铸造。

第二讲:mint 源码精读

简介

本文精读 Compound v2 的 Supply / Mint 主线,梳理用户调用 CErc20.mint 后,资金如何进入 CToken.mintInternal、先执行 accrueInterest(),再通过 mintFresh 完成风控检查、兑换率计算、转账和 cToken 铸造。读完可以理解为什么 mint 前必须先计息,以及 mintTokens = actualMintAmount / exchangeRate 这条公式背后的设计。

用户供应 ERC20 资产时,调用的是对应 cToken 合约的 mint

比如:

用户存 DAI  -> 调用 cDAI.mint(mintAmount)
用户存 USDC -> 调用 cUSDC.mint(mintAmount)

这一讲我们主要看 ERC20 版本,也就是 CErc20.sol + CToken.sol


1. 从入口开始:CErc20.mint

CErc20.sol 里的入口非常短:

function mint(uint mintAmount) external returns (uint) {
    mintInternal(mintAmount);
    return NO_ERROR;
}

表面上看就两行,但它已经透露了 Compound v2 的一个风格:

外层入口很薄,核心逻辑都在 CToken.sol 里。

CErc20 只是 ERC20 市场的外壳。真正处理 mint 的是:

mintInternal(mintAmount);

2. 进入 CToken:mintInternal

CToken.sol 里大概是这个结构:

function mintInternal(uint mintAmount) internal nonReentrant {
    accrueInterest();
    mintFresh(msg.sender, mintAmount);
}

这里有两个重点。

第一,nonReentrant

这是防重入修饰器,防止外部 token 转账、回调等过程中重入 Compound 合约。虽然大部分标准 ERC20 没有回调,但 DeFi 协议不能假设所有 token 都乖。

第二,accrueInterest()

Compound v2 的所有核心操作几乎都先计息:

mint 前先计息
redeem 前先计息
borrow 前先计息
repay 前先计息
liquidate 前先计息

为什么 mint 前也要计息?

因为用户 mint cToken 时,要用当前 exchangeRate。

如果利息没有先更新,那么 exchangeRate 就是旧的。新用户可能用旧价格买到过多 cToken,稀释老用户收益。

所以流程必须是:

先更新市场状态
再按最新 exchangeRate 给用户铸造 cToken

这就是 mintInternal -> accrueInterest -> mintFresh 的含义。


3. Fresh 是什么意思?

mintFresh 这个名字非常重要。

Compound v2 的函数命名里经常有:

xxxInternal
xxxFresh

比如:

mintInternal / mintFresh
redeemInternal / redeemFresh
borrowInternal / borrowFresh
repayBorrowInternal / repayBorrowFresh
liquidateBorrowInternal / liquidateBorrowFresh

它们的分工是:

xxxInternal:
  先 accrueInterest,把市场状态更新到当前区块

xxxFresh:
  假设市场已经是 fresh 状态,执行业务逻辑

所以 mintFresh 的隐含前提是:

accrualBlockNumber == 当前区块号

如果不是当前区块,源码里会报错或拒绝继续。

这能防止有人绕过计息流程,直接在旧状态下操作。


4. mintFresh 的整体结构

mintFresh 可以简化成这样:

function mintFresh(address minter, uint mintAmount) internal {
    // 1. 问 Comptroller:这次 mint 是否允许?
    comptroller.mintAllowed(address(this), minter, mintAmount);

    // 2. 确认当前市场已经完成计息
    require(accrualBlockNumber == getBlockNumber());

    // 3. 计算当前 exchangeRate
    uint exchangeRate = exchangeRateStoredInternal();

    // 4. 把 underlying 从用户转进 cToken 合约
    uint actualMintAmount = doTransferIn(minter, mintAmount);

    // 5. 计算应该铸造多少 cToken
    uint mintTokens = actualMintAmount / exchangeRate;

    // 6. 更新 totalSupply 和用户 cToken 余额
    totalSupply += mintTokens;
    accountTokens[minter] += mintTokens;

    // 7. 触发事件
    emit Mint(minter, actualMintAmount, mintTokens);
    emit Transfer(address(0), minter, mintTokens);
}

真实源码里用了 Exp 定点数库、错误码和安全数学,结构会更繁琐,但核心就是这 7 步。

我们一层层拆。


5. 第一步:Comptroller.mintAllowed

mintFresh 里第一件事通常是问 Comptroller

uint allowed = comptroller.mintAllowed(address(this), minter, mintAmount);
if (allowed != 0) {
    revert MintComptrollerRejection(allowed);
}

这句话的意思是:

这个用户能不能给这个市场供应资产?

你可能会问:供应资产不是增加系统流动性吗?为什么还要检查?

因为协议需要支持市场开关和风控策略。

Comptroller 可以决定:

这个市场是否已被支持
这个市场是否暂停 mint
这个调用是否违反某些治理参数

在正常情况下,供应操作大概率会被允许。但 Compound 依然把权限检查放在前面。

设计思想是:

所有用户操作都必须先经过 Comptroller 许可。

CToken 不自己判断全局风险策略,它只问风控中心。


6. 第二步:确认市场是 fresh 的

接着源码会检查:

if (accrualBlockNumber != getBlockNumber()) {
    revert MintFreshnessCheck();
}

意思是:

如果这个市场还没有计息到当前区块,就不能继续 mint。

这看起来有点重复,因为 mintInternal 已经调用过 accrueInterest() 了。

但这是防御式编程。

原因包括:

accrueInterest 可能失败
有人可能通过内部路径调用 fresh 函数
未来代码升级可能改变调用链

所以 mintFresh 自己也验证一遍:

我只处理 fresh 状态下的 mint。

这就是 Compound v2 很典型的安全风格:宁愿啰嗦,也要把状态前提写死。


7. 第三步:计算 exchangeRate

接下来:

uint exchangeRateMantissa = exchangeRateStoredInternal();

这是 mint 的核心价格。

exchangeRateStoredInternal() 大致逻辑是:

if (totalSupply == 0) {
    return initialExchangeRateMantissa;
} else {
    uint totalCash = getCashPrior();
    uint cashPlusBorrowsMinusReserves =
        totalCash + totalBorrows - totalReserves;

    return cashPlusBorrowsMinusReserves / totalSupply;
}

也就是:

exchangeRate = (cash + totalBorrows - totalReserves) / totalSupply

这个公式一定要背下来。

它表示:

每 1 个 cToken 当前能兑换多少 underlying

举个例子。

假设市场状态是:

cash = 1,000 DAI
totalBorrows = 500 DAI
totalReserves = 50 DAI
totalSupply = 72,500 cDAI

那么:

totalAssets = 1,000 + 500 - 50 = 1,450 DAI

exchangeRate = 1,450 / 72,500 = 0.02 DAI per cDAI

如果用户新存入 100 DAI:

mintTokens = 100 / 0.02 = 5,000 cDAI

用户拿到 5,000 cDAI。


8. 为什么第一次 mint 要用 initialExchangeRate?

如果 totalSupply == 0,市场里还没有任何 cToken。

这时候不能用:

exchangeRate = totalAssets / totalSupply

因为分母是 0。

所以 Compound v2 为每个市场设置一个初始兑换率:

initialExchangeRateMantissa

第一次 mint 时:

mintTokens = mintAmount / initialExchangeRate

这个初始值由部署市场时设定。

它的作用不是给早期用户特别优势,而是确定 cToken 的初始单位比例。

例如:

1 cDAI = 0.02 DAI

那么用户第一次存 100 DAI,会得到:

100 / 0.02 = 5,000 cDAI

后面随着利息累积,exchangeRate 会逐渐上升。


9. 第四步:doTransferIn

接下来是真正把 underlying 从用户转进合约:

uint actualMintAmount = doTransferIn(minter, mintAmount);

注意这里不是简单写:

underlying.transferFrom(minter, address(this), mintAmount);

而是封装成 doTransferIn

原因是 Compound v2 要兼容一些“不标准 ERC20”。

有些 ERC20:

transferFrom 返回 bool

有些:

transferFrom 不返回值

还有一些 token 可能有转账手续费,实际到账数量小于用户传入的 mintAmount

所以 Compound 会用转账前后余额差来判断实际到账:

uint balanceBefore = EIP20Interface(underlying).balanceOf(address(this));

EIP20NonStandardInterface(underlying).transferFrom(from, address(this), amount);

uint balanceAfter = EIP20Interface(underlying).balanceOf(address(this));

return balanceAfter - balanceBefore;

所以这里返回的是:

actualMintAmount

不是盲目相信用户传入的 mintAmount

这非常重要。

因为如果某个 token 有手续费,比如用户传入 100,合约实际只收到 99,那么必须按 99 来 mint cToken。

否则用户会白拿多余 cToken,池子被稀释。


10. 第五步:计算 mintTokens

拿到 actualMintAmount 后,计算用户应该得到多少 cToken:

mintTokens = actualMintAmount / exchangeRate

真实源码里大概是类似:

mintTokens = div_(actualMintAmount, exchangeRate);

因为 Compound 用的是 mantissa 定点数。

直觉是:

你付了多少 underlying / 当前每个 cToken 多少钱 = 你能拿多少 cToken

例如:

exchangeRate = 0.02 DAI / cDAI
actualMintAmount = 100 DAI

mintTokens = 100 / 0.02 = 5,000 cDAI

如果过一段时间,利息变多了:

exchangeRate = 0.025 DAI / cDAI
actualMintAmount = 100 DAI

mintTokens = 100 / 0.025 = 4,000 cDAI

你会发现:同样存 100 DAI,后来的用户拿到的 cDAI 更少。

这不是吃亏,而是因为每个 cDAI 价值更高了。


11. 第六步:更新 totalSupply 和用户余额

计算完 mintTokens 后,更新状态:

totalSupply = totalSupply + mintTokens;
accountTokens[minter] = accountTokens[minter] + mintTokens;

这两个变量是 cToken 的 ERC20 供应量和用户余额。

等价于:

给 minter 铸造 mintTokens 个 cToken

然后触发事件:

emit Mint(minter, actualMintAmount, mintTokens);
emit Transfer(address(0), minter, mintTokens);

为什么还有 Transfer(address(0), minter, mintTokens)

因为 cToken 本身是 ERC20。

铸造 ERC20 token 时,习惯上从零地址转给用户:

address(0) -> minter

这样区块浏览器和钱包都能识别这是一次 mint。


12. 为什么先 doTransferIn,再更新余额?

这个问题很关键。

你可能会觉得:

先算 mintTokens
先加用户 cToken 余额
最后再 transferFrom

不也行吗?

不行。

因为外部 token 转账是高风险操作:

可能失败
可能实际到账金额少于预期
可能遇到非标准 ERC20
可能触发意外行为

所以安全顺序是:

先确认钱真的到了
再按实际到账金额 mint cToken

Compound 的顺序是:

1. 算 exchangeRate
2. 转入 underlying,得到 actualMintAmount
3. 按 actualMintAmount 算 mintTokens
4. 更新用户 cToken 余额和 totalSupply

这里有个细节:为什么 exchangeRate 要在转账前计算?

因为如果先转账,cash 会增加,exchangeRate 会被用户自己的这笔存款影响。

举个极端例子:

原本池子:
cash = 100
borrows = 0
reserves = 0
totalSupply = 5,000

exchangeRate = 100 / 5,000 = 0.02

用户存 100。

正确做法是按旧池子的 exchangeRate:

mintTokens = 100 / 0.02 = 5,000

存完后:

cash = 200
totalSupply = 10,000
exchangeRate = 200 / 10,000 = 0.02

如果你先转账再算 exchangeRate:

cash = 200
totalSupply = 5,000

exchangeRate = 200 / 5,000 = 0.04
mintTokens = 100 / 0.04 = 2,500

用户会少拿一半 cToken。

所以正确顺序必须是:

先用转账前 cash 算 exchangeRate
再转入 underlying
再 mint cToken

源码里的 getCashPrior() 这个名字也在强调:

Prior = 用户这次操作发生前的 cash

13. getCashPrior 是什么?

getCashPrior() 对 ERC20 市场来说,大概是:

function getCashPrior() internal view returns (uint) {
    EIP20Interface token = EIP20Interface(underlying);
    return token.balanceOf(address(this));
}

也就是:

当前 cToken 合约持有多少 underlying

但语义上它代表:

执行本次 transferIn / transferOut 之前的现金余额

在 mint 里,它必须在 doTransferIn 之前被用于 exchangeRate 计算。

所以流程顺序是刻意设计的,不是随便写的。


14. Supply 操作不会直接改变 totalBorrows

这点也要注意。

用户 mint,也就是供应资产,会改变:

cash 增加
totalSupply 增加
accountTokens[minter] 增加

但不会直接改变:

totalBorrows
accountBorrows

因为用户只是存款,不是借款。

不过,由于 mint 前会 accrueInterest(),所以在这次交易里,totalBorrows 可能会因为计息而先增长。

也就是说,一次 mint 交易里可能看到:

totalBorrows 变大
totalReserves 变大
borrowIndex 变大
cash 变大
totalSupply 变大
accountTokens[minter] 变大

其中前 3 个变化来自 accrueInterest(),后 3 个变化来自 mintFresh()

这个区分很重要。


15. 用完整例子走一遍

假设 DAI 市场当前状态:

cash = 1,000 DAI
totalBorrows = 500 DAI
totalReserves = 0 DAI
totalSupply = 75,000 cDAI

exchangeRate = (1,000 + 500 - 0) / 75,000
             = 0.02 DAI / cDAI

Alice 要供应:

100 DAI

流程:

1. Alice 调用 cDAI.mint(100)
2. 合约 accrueInterest()
3. Comptroller.mintAllowed(cDAI, Alice, 100)
4. exchangeRateStoredInternal() 得到 0.02
5. doTransferIn(Alice, 100)
6. actualMintAmount = 100
7. mintTokens = 100 / 0.02 = 5,000
8. totalSupply = 80,000
9. accountTokens[Alice] += 5,000
10. cDAI 合约 cash 增加到 1,100

操作后:

cash = 1,100
totalBorrows = 500
totalReserves = 0
totalSupply = 80,000

exchangeRate = (1,100 + 500) / 80,000
             = 0.02

你看,Alice 的这次 mint 不会改变 exchangeRate。

这很合理。 存款只是按当前价格买入 cToken,不应该让自己因为“刚存进去”就赚或亏。


16. 如果市场已经有利息呢?

假设过了一段时间,借款人产生了利息。

市场变成:

cash = 1,000 DAI
totalBorrows = 600 DAI
totalReserves = 0 DAI
totalSupply = 75,000 cDAI

那么:

exchangeRate = (1,000 + 600) / 75,000
             = 0.021333... DAI / cDAI

Alice 现在存 100 DAI:

mintTokens = 100 / 0.021333...
           ≈ 4,687.5 cDAI

她拿到的 cDAI 比之前少。

但每个 cDAI 更值钱了,所以 Alice 并没有吃亏。

这体现了 Compound v2 的供应收益模型:

老用户持有的 cToken 数量不变
但 exchangeRate 增加
所以可赎回 underlying 增加

17. 小心:mint 不是“存款余额增加”

传统银行 App 里,你存 100,账户余额显示 100。

但 Compound v2 不这样记录。

Compound 记录的是:

你有多少 cToken

你的 underlying 价值要临时算:

underlyingBalance = cTokenBalance * exchangeRate

所以 Compound v2 的“存款余额”其实不是一个单独变量。

没有这种东西:

mapping(address => uint) userSuppliedUnderlying;

它没有直接记:

Alice 存了多少 DAI

它只记:

Alice 有多少 cDAI

至于 Alice 现在能拿回多少 DAI,要用当前 exchangeRate 算。

这是理解 Compound v2 的关键。


18. ERC20 Mint 和 ETH Mint 的区别

刚才讲的是 CErc20.mint(uint mintAmount)

ETH 市场,也就是 CEther,入口不一样。

ETH 没有 transferFrom,用户直接随交易发送 ETH:

function mint() external payable {
    mintInternal(msg.value);
}

流程基本一样:

CEther.mint()
  -> mintInternal(msg.value)
    -> accrueInterest()
    -> mintFresh(msg.sender, msg.value)

区别主要在转账:

CErc20:
  doTransferIn 通过 transferFrom 拉 ERC20

CEther:
  msg.value 就是用户发送的 ETH

但核心公式不变:

mintTokens = actualMintAmount / exchangeRate

19. mint 这条线的源码骨架

把今天所有东西压缩成一张图:

CErc20.mint(mintAmount)
 |
 v
CToken.mintInternal(mintAmount)
 |
 |-- nonReentrant
 |
 |-- accrueInterest()
 |
 v
CToken.mintFresh(minter, mintAmount)
 |
 |-- comptroller.mintAllowed()
 |
 |-- require market is fresh
 |
 |-- exchangeRateStoredInternal()
 |
 |-- doTransferIn(minter, mintAmount)
 |
 |-- mintTokens = actualMintAmount / exchangeRate
 |
 |-- totalSupply += mintTokens
 |
 |-- accountTokens[minter] += mintTokens
 |
 |-- emit Mint
 |-- emit Transfer(address(0), minter, mintTokens)

你能把这张图复述出来,mint 主线就算通了。


20. 本章最重要的 6 个结论

第一,CErc20.mint 只是入口,核心逻辑在 CToken.mintInternal / mintFresh

第二,mint 前必须先 accrueInterest(),否则会用旧 exchangeRate,稀释老用户。

第三,mintFresh 要检查 accrualBlockNumber == 当前区块,确保市场状态是 fresh。

第四,mintTokens 的核心公式是:

mintTokens = actualMintAmount / exchangeRate

第五,exchangeRate 必须用本次转账前的 cash 计算,否则用户自己的存款会影响自己的 mint 价格。

第六,Compound 不记录“用户存了多少 underlying”,只记录“用户有多少 cToken”。


之后可以盯住这几个源码点

下一次打开源码时,重点找这些名字:

CErc20.mint
CToken.mintInternal
CToken.mintFresh
accrueInterest
exchangeRateStoredInternal
getCashPrior
doTransferIn
accountTokens
totalSupply

第三讲我们就读 accrueInterest()。 这会是 Compound v2 最核心、也最漂亮的一讲:为什么不遍历所有用户,也能让所有债务自动增长。

相关文章

0 条评论