Compound v2 学习

存款之后 cToken 的余额一直不动,涨的是兑换率。跟着 CToken.sol 和 Comptroller.sol 走一遍:利息怎么靠一个 borrowIndex 往前推、利率对利用率为什么是折线而不是曲线、清算时那笔抵押品按什么公式算出来。

预计
9 分钟

往 Compound 存 1000 USDC,你拿到的不是 1000 个什么东西,是一串看起来很奇怪的数字,比如 49876.54321 cUSDC。放一年再看,这个数字一个也没变。变的是它能换回多少 USDC。

利息不体现在余额上,体现在兑换率上。这一条决定了整个 v2 的记账方式:协议从不遍历账户,也从不给谁「打」一笔利息。下面按 CToken.solComptroller.sol 两个文件走一遍。

利息不逐个账户结算,只把一个 borrowIndex 往前推

任何会改变资金池状态的操作 —— 存、取、借、还、清算 —— 第一件事都是调 accrueInterest()。它把「从上次结息到现在」这段时间的利息一次性算出来,记进几个全局变量:

compound-protocol · CToken.sol · accrueInterest(节选)
/* 距离上次结息过了多少个区块 */
uint blockDelta = currentBlockNumber - accrualBlockNumberPrior;
/* 单利因子 = 每区块借款利率 × 区块数 */
Exp memory simpleInterestFactor = mul_(Exp({mantissa: borrowRateMantissa}), blockDelta);
/* 这段时间全池产生的利息总额 */
uint interestAccumulated = mul_ScalarTruncate(simpleInterestFactor, borrowsPrior);
/* 全部记进总借款 —— 没有转账,只是数字变大 */
uint totalBorrowsNew = interestAccumulated + borrowsPrior;
/* 其中 reserveFactor 的那一份归协议储备 */
uint totalReservesNew = mul_ScalarTruncateAddUInt(Exp({mantissa: reserveFactorMantissa}), interestAccumulated, reservesPrior);
/* 借款指数按同样的比例往前推一格 */
uint borrowIndexNew = mul_ScalarTruncateAddUInt(simpleInterestFactor, borrowIndexPrior, borrowIndexPrior);

第 2 行的 blockDelta区块数,不是秒数。v2 里所有利率都是「每区块」的(baseRatePerBlockmultiplierPerBlock),换算成年化要乘以一年的出块数 —— 这个数在合并前后变过,所以同一个 multiplierPerBlock 在不同时期对应的 APY 并不一样。

整个函数没有循环。它不关心池子里有一千个还是十万个借款人,只把总量往上加,再把 borrowIndex 按同一个比例推一格。单个借款人欠多少,是用的时候现算的:

compound-protocol · CToken.sol · borrowBalanceStoredInternal
function borrowBalanceStoredInternal(address account) internal view returns (uint) {
BorrowSnapshot storage borrowSnapshot = accountBorrows[account];
if (borrowSnapshot.principal == 0) {
return 0;
}
uint principalTimesIndex = borrowSnapshot.principal * borrowIndex;
return principalTimesIndex / borrowSnapshot.interestIndex;
}

BorrowSnapshot 只有两个字段:借的时候的本金 principal,和借的那一刻 borrowIndex 的读数 interestIndex。当前欠款 = 本金 × (现在的指数 / 当时的指数)。中间这段时间结息过多少次、每次利率各是多少,都不影响结果,只有首尾两个读数参与计算。

这个套路在链上到处都是。Perpetual Protocol 三:ClearingHouse的资金费用用的是同一个思路 —— 维护一个全局累加量,每个人自己记着上次结清时的读数,用的时候算差。把「对所有人做一遍」换成「维护一个全局量,各自算差」,是链上绕开「gas 随用户数线性增长」的标准解法。

兑换率就是「池子里有多少」除以「份额发出去多少」

存款人这一侧没有指数,因为 cToken 本身就是份额:

compound-protocol · CToken.sol · exchangeRateStoredInternal
function exchangeRateStoredInternal() virtual internal view returns (uint) {
uint _totalSupply = totalSupply;
if (_totalSupply == 0) {
return initialExchangeRateMantissa;
} else {
uint totalCash = getCashPrior();
uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;
return exchangeRate;
}
}

分子那三项是这个市场真正属于存款人的资产:合约里还躺着的现金,加上借出去的(连本带利,因为 totalBorrows 刚被 accrueInterest 推高过),减去归协议的储备。分母是 cToken 的总发行量。

借款人的利息进了 totalBorrowstotalBorrows 在分子里,于是兑换率上涨,每一个 cToken 能换回更多 underlying。存款人的收益就是这么到账的:没有转账,没有事件,只是一个除法的分子变大了。

池子空的时候返回 initialExchangeRateMantissa,部署时设的常数(历史上常见的取值让 1 个 underlying 大约换 50 个 cToken,所以你的 cToken 余额总是个很大的数)。这个初始值不能太小,否则早期的整数除法会把小额存款直接截断成 0 份。

利率对利用率是一条折线,不是双曲线

利用率的定义是「借出去的占多少」:

compound-protocol · BaseJumpRateModelV2.sol · 利用率与折线利率
function utilizationRate(uint cash, uint borrows, uint reserves) public pure returns (uint) {
if (borrows == 0) { return 0; }
return borrows * BASE / (cash + borrows - reserves);
}
function getBorrowRateInternal(uint cash, uint borrows, uint reserves) internal view returns (uint) {
uint util = utilizationRate(cash, borrows, reserves);
if (util <= kink) {
return ((util * multiplierPerBlock) / BASE) + baseRatePerBlock;
} else {
uint normalRate = ((kink * multiplierPerBlock) / BASE) + baseRatePerBlock;
uint excessUtil = util - kink;
return ((excessUtil * jumpMultiplierPerBlock) / BASE) + normalRate;
}
}

两段都是一次函数,在 kink 那一点接起来。低于拐点,斜率是 multiplierPerBlock;高于拐点,超出的那部分换成陡得多的 jumpMultiplierPerBlock。图像是一条折线,不是任何一种连续曲线,利率也不会在利用率趋近 100% 时冲向无穷 —— 它在 util = 1 处有一个确定的有限值,由两个斜率和拐点位置算得出来。

拐点存在的理由是流动性,不是收入。利用率接近 1 意味着合约里的现金快见底,存款人取不出钱。把拐点之后的斜率调陡,是用价格把借款人赶走、把新的存款吸进来。JumpRateModel 出现之前的 WhitePaperInterestRateModel 只有一段直线,没有拐点。

存款利率不是「借款利率减掉储备那一刀」,中间还乘了一次利用率:

compound-protocol · BaseJumpRateModelV2.sol · getSupplyRate
function getSupplyRate(uint cash, uint borrows, uint reserves, uint reserveFactorMantissa) virtual public view returns (uint) {
uint oneMinusReserveFactor = BASE - reserveFactorMantissa;
uint borrowRate = getBorrowRateInternal(cash, borrows, reserves);
uint rateToPool = borrowRate * oneMinusReserveFactor / BASE;
return utilizationRate(cash, borrows, reserves) * rateToPool / BASE;
}

最后一行那个 utilizationRate(...) * 是关键。利息只由借出去的那部分产生,却要分给全部存款人。利用率 50% 时,存款人分到的还不到借款利率的一半。这就是为什么前端上存款 APY 总是显著低于借款 APY,而两者的差额远不止一个储备因子。

风控不在 cToken 里,在 Comptroller 的 xxxAllowed 钩子上

CErc20CEther 只管资产的进出,一个包 ERC20、一个包原生 ETH,都继承自 CToken。要不要放行这笔操作,CToken 自己不判断,它去问 ComptrollermintAllowedredeemAllowedborrowAllowedrepayBorrowAllowedliquidateBorrowAllowedtransferAllowed,每个用户动作对应一个钩子。

存款与赎回的时序:CToken.mint 先 accrueInterest,再问 Comptroller 的 mintAllowed,拿回的是一个 uint 错误码 —— 非 0 就整笔不做且不 revert,为 0 才 transferFrom 收进资产、按汇率铸出 cToken;redeem 走对称的反向路径 存款与赎回的时序:CToken.mint 先 accrueInterest,再问 Comptroller 的 mintAllowed,拿回的是一个 uint 错误码 —— 非 0 就整笔不做且不 revert,为 0 才 transferFrom 收进资产、按汇率铸出 cToken;redeem 走对称的反向路径

这些钩子返回错误码,不 revert。v2 通篇是这套约定:0 表示放行,非零是一个 Error 枚举值,调用方拿到之后自己决定报错还是继续。设计年份早于 Solidity 的自定义错误,代价是每一层都要显式检查返回值,漏检一次就是一个静默放行的漏洞。

抵押品要先 enterMarkets 才算数。没进用户市场列表的 cToken,在 getAccountLiquidity 遍历时根本不会被看到 —— 你可以存了一大笔 cETH 却借不出任何东西,只因为没调过那个函数。反过来 exitMarket 会先检查退出后是否仍然健康,欠着钱就退不掉。

getAccountLiquidity 是唯一需要循环的地方:它遍历用户进过的每一个市场,把抵押品按各自的 collateralFactorMantissa 打折累加,再减去全部借款的价值。折扣因子是每个市场单独配的,稳定币高、波动大的资产低,这是协议表达风险偏好的主要旋钮。

清算是别人替你还钱,然后按折扣拿走你的抵押品

抵押品折算后的价值低于借款价值,任何地址都可以调 liquidateBorrow 替你还掉一部分欠款,并按折扣拿走对应的抵押品 cToken。两个治理参数卡着这件事的边界:

  • closeFactorMantissa —— 一次最多能替借款人还掉其某笔欠款的多大比例。它防的是「一次全平」,让被清算的人还有翻身余地。
  • liquidationIncentiveMantissa —— 清算人能多拿走的那一截,也就是干这件事的报酬。

具体拿走多少 cToken 由 Comptroller.liquidateCalculateSeizeTokens 算:

liquidateCalculateSeizeTokens 的公式(源码注释原文)
seizeAmount = actualRepayAmount * liquidationIncentive * priceBorrowed / priceCollateral
seizeTokens = seizeAmount / exchangeRate

两个价格都来自 PriceOracle,最后除以兑换率是因为扣的是 cToken 而不是 underlying。整个过程里被清算的人不需要签任何字,也收不到任何通知 —— 抵押不足这个状态本身就是授权。

清算靠外部激励驱动,没有人有义务来做。行情快速下跌、gas 飙升的时候清算可能来得太晚,仓位穿仓,缺口落到 totalReserves 上。这条路径和 Aave v3 学习里那套清算的差别,主要在参数怎么分层,而不在机制。

COMP 分发是同一个累加器套路的第三次出现

Comptroller 给每个市场维护两个 CompMarketState,各有一个 index 字段:compSupplyStatecompBorrowState。每次有人动这个市场,先按流逝的区块数和该市场的分发速度把 index 往前推,推的幅度是「这段时间发出去的 COMP 除以该市场的总份额」。

每个用户身上记着 compSupplierIndex[cToken][user](借款侧是 compBorrowerIndex),是他上次结算时那个 index 的读数。distributeSupplierComp 做的就是拿当前 index 减去用户记着的读数,乘以他的 cToken 余额,累加进待领取额度,然后把读数刷新成最新值。

borrowIndex、和永续合约的资金费用,是完全一样的结构。区别只在于累加的东西:那两个累加的是利息比例,这个累加的是「每一份额累计该拿多少 COMP」。链上的分发问题最后几乎都会收敛到这个形状。

这套设计的代价

账面数字是滞后的。 直接读 accountBorrows[user].principal 拿到的是上次操作时的本金,不是当前欠款;totalBorrows 也停在上一次 accrueInterest 的时刻。想要实时值得走 borrowBalanceCurrent() 这类会先结息的非 view 函数,或者自己在链下把指数补上。

按区块计息把利率绑在了出块速度上。 出块间隔一变,同一组参数对应的年化就变了。后来的协议(包括 Compound III)改成按秒计息,正是为了拿掉这个隐含依赖。

每个市场是一个独立的 cToken 合约。 上一个新资产要部署一套合约、配一套参数、过一次治理,池子之间也不共享流动性。这是 v2 和后来的单池架构最根本的分歧。

错误码约定容易埋雷。 返回 uint 而不是 revert,意味着任何一个调用点忘了检查返回值,失败都会被当成成功继续走下去。这套约定在 v2 之后就没人再用了。

利用率高时存款人取不出钱。 兑换率再高也只是记账,真要赎回得有现金。利用率贴近拐点时,大额存款人会发现自己被锁在里面 —— 折线利率是缓解手段,不是保证。