Compound v2 学习
存款之后 cToken 的余额一直不动,涨的是兑换率。跟着 CToken.sol 和 Comptroller.sol 走一遍:利息怎么靠一个 borrowIndex 往前推、利率对利用率为什么是折线而不是曲线、清算时那笔抵押品按什么公式算出来。
往 Compound 存 1000 USDC,你拿到的不是 1000 个什么东西,是一串看起来很奇怪的数字,比如 49876.54321 cUSDC。放一年再看,这个数字一个也没变。变的是它能换回多少 USDC。
利息不体现在余额上,体现在兑换率上。这一条决定了整个 v2 的记账方式:协议从不遍历账户,也从不给谁「打」一笔利息。下面按 CToken.sol 和 Comptroller.sol 两个文件走一遍。
利息不逐个账户结算,只把一个 borrowIndex 往前推
任何会改变资金池状态的操作 —— 存、取、借、还、清算 —— 第一件事都是调 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 里所有利率都是「每区块」的(baseRatePerBlock、multiplierPerBlock),换算成年化要乘以一年的出块数 —— 这个数在合并前后变过,所以同一个 multiplierPerBlock 在不同时期对应的 APY 并不一样。
整个函数没有循环。它不关心池子里有一千个还是十万个借款人,只把总量往上加,再把 borrowIndex 按同一个比例推一格。单个借款人欠多少,是用的时候现算的:
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 本身就是份额:
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 的总发行量。
借款人的利息进了 totalBorrows,totalBorrows 在分子里,于是兑换率上涨,每一个 cToken 能换回更多 underlying。存款人的收益就是这么到账的:没有转账,没有事件,只是一个除法的分子变大了。
池子空的时候返回 initialExchangeRateMantissa,部署时设的常数(历史上常见的取值让 1 个 underlying 大约换 50 个 cToken,所以你的 cToken 余额总是个很大的数)。这个初始值不能太小,否则早期的整数除法会把小额存款直接截断成 0 份。
利率对利用率是一条折线,不是双曲线
利用率的定义是「借出去的占多少」:
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 只有一段直线,没有拐点。
存款利率不是「借款利率减掉储备那一刀」,中间还乘了一次利用率:
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 钩子上
CErc20 和 CEther 只管资产的进出,一个包 ERC20、一个包原生 ETH,都继承自 CToken。要不要放行这笔操作,CToken 自己不判断,它去问 Comptroller:mintAllowed、redeemAllowed、borrowAllowed、repayBorrowAllowed、liquidateBorrowAllowed、transferAllowed,每个用户动作对应一个钩子。
这些钩子返回错误码,不 revert。v2 通篇是这套约定:0 表示放行,非零是一个 Error 枚举值,调用方拿到之后自己决定报错还是继续。设计年份早于 Solidity 的自定义错误,代价是每一层都要显式检查返回值,漏检一次就是一个静默放行的漏洞。
抵押品要先 enterMarkets 才算数。没进用户市场列表的 cToken,在 getAccountLiquidity 遍历时根本不会被看到 —— 你可以存了一大笔 cETH 却借不出任何东西,只因为没调过那个函数。反过来 exitMarket 会先检查退出后是否仍然健康,欠着钱就退不掉。
getAccountLiquidity 是唯一需要循环的地方:它遍历用户进过的每一个市场,把抵押品按各自的 collateralFactorMantissa 打折累加,再减去全部借款的价值。折扣因子是每个市场单独配的,稳定币高、波动大的资产低,这是协议表达风险偏好的主要旋钮。
清算是别人替你还钱,然后按折扣拿走你的抵押品
抵押品折算后的价值低于借款价值,任何地址都可以调 liquidateBorrow 替你还掉一部分欠款,并按折扣拿走对应的抵押品 cToken。两个治理参数卡着这件事的边界:
closeFactorMantissa—— 一次最多能替借款人还掉其某笔欠款的多大比例。它防的是「一次全平」,让被清算的人还有翻身余地。liquidationIncentiveMantissa—— 清算人能多拿走的那一截,也就是干这件事的报酬。
具体拿走多少 cToken 由 Comptroller.liquidateCalculateSeizeTokens 算:
seizeAmount = actualRepayAmount * liquidationIncentive * priceBorrowed / priceCollateralseizeTokens = seizeAmount / exchangeRate两个价格都来自 PriceOracle,最后除以兑换率是因为扣的是 cToken 而不是 underlying。整个过程里被清算的人不需要签任何字,也收不到任何通知 —— 抵押不足这个状态本身就是授权。
清算靠外部激励驱动,没有人有义务来做。行情快速下跌、gas 飙升的时候清算可能来得太晚,仓位穿仓,缺口落到 totalReserves 上。这条路径和 Aave v3 学习里那套清算的差别,主要在参数怎么分层,而不在机制。
COMP 分发是同一个累加器套路的第三次出现
Comptroller 给每个市场维护两个 CompMarketState,各有一个 index 字段:compSupplyState 和 compBorrowState。每次有人动这个市场,先按流逝的区块数和该市场的分发速度把 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 之后就没人再用了。
利用率高时存款人取不出钱。 兑换率再高也只是记账,真要赎回得有现金。利用率贴近拐点时,大额存款人会发现自己被锁在里面 —— 折线利率是缓解手段,不是保证。