二、从源码讲解compound v2 mint源码精读
本文精读 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 最核心、也最漂亮的一讲:为什么不遍历所有用户,也能让所有债务自动增长。