前言在网上看了不少Compound的源码资料,但脑子始终感觉有点混乱,不如自己也再梳理总结一下,作为自己的学习记录。准备本次看的是CompoundV2的版本,而V3版本于2022年8月26日上线的以太坊主网。V2版本源码阅读门槛低一下,思想也类似,理解了V2再去看V3应该会更容易。
在网上看了不少Compound的源码资料,但脑子始终感觉有点混乱,不如自己也再梳理总结一下,作为自己的学习记录。
本次看的是Compound V2的版本,而V3版本于2022年8月26日上线的以太坊主网。V2版本源码阅读门槛低一下,思想也类似,理解了V2再去看V3应该会更容易。关于V3的升级点,可以看这篇 compound v2 v3 对比。
下面是一些准备工作:
页面主要有两个,一个是Dashboard、一个是Markets页面。站点有点体验不好的是,不支持切换连接Goerli等测试网,所以这里我也无法演示了。而Aave的站点是可以让你连接测试网的进行玩玩的。
Dashboard页面
下面是页面的一些信息:
点击 Collateral 下的开关按钮,会有一个弹窗确认是否要将你的存款作为抵押品: 这里开启货币作为抵押品后,就可以借入其他数字资产了。例如,可以将 ETH 作为抵押品来借取 Dai。
点击Supply Martets中的某个币种,会有一个弹窗,可以进行Supply和Withdraw操作:
点击Borrow Martets中的某个币种,可以进行Borrow(借款)和Repay(还款)操作:
Markets页面
下面是市场页面的一些市场信息,以Dai为例:
后面我们要分析合约是如何实现存款取款、借款还款操作,以及页面上对应利率和数据的计算,我们就能大概能看懂整个项目了。
项目是使用一个叫 eth-saddle 的框架搭建的,感觉作者喜欢用一些奇奇怪怪的框架,而在V3项目已经换成Hardhat了。整个项目我们主要关注的是contracts文件夹:
合约文件虽然有点多,但主要可以分为四个模块:
那它为什么叫CToken合约,不像Aave源码一样搞个Pool、SupplyLogic等合约?首先要理解的是 CToken 概念,用户在存款后,要给用户一个凭证,早期的简单实现是直接在合约里搞个mapping记录用户的存款余额之类的,而现在代币化的好处是更具象化,并且作为代币的凭证也可以更好的转让或质押。 每种抵押品都会部署其对应其cToken代币合约,如cDAI、cETH等,相关cToken地址可以在官网查看:
看源码我们了解整体项目后,去繁化简,阅读其核心逻辑,其余边边角角有空可以慢慢看,这里我主要阅读CToken
、InterestRateModel
、Comptroller
。
存款、取款、借款、还款操作分别对应 CToken.sol 中的 mintInternal
、redeemInternal
、borrowInternal
、repayBorrowInternal
。
存款函数
function mintInternal(uint mintAmount) internal nonReentrant {
accrueInterest();
// mintFresh emits the actual Mint event if successful and logs on errors, so we don't need to
mintFresh(msg.sender, mintAmount);
}
查看 mintInternal
的调用栈:
这几个操作前都会调用 accrueInterest
进行计算新的利息,这里后续讲 InterestRateModel 合约时再看,我们先看 mintFresh
操作。
可以看到mintFresh有以下操作: comptroller合约检查、汇率计算、将抵押品转入合约、根据汇率计算获得的 cToken 数量、计算 cToken totalSupply,以及用户对应的总cToken数量。
这里了解存款操作的大概流程就是将抵押品转入合约,根据汇率获取一定量的cToken。
取款函数
取款函数逻辑也差不多,就是根据 exchangeRateStoredInternal
的汇率将抵押品赎回,归还一定量的cToken,利润通过汇率的变化来获取。这个函数提供了 uint redeemTokensIn
和 uint redeemAmountIn
参数,意思你可以给定抵押品量或者给定cToken量来进行取款。
借款函数
通过 borrowBalanceStoredInternal
计算用户的借款总数 accountBorrowsPrev
(包含利息),加上本次借款数 borrowAmount,存到 accountBorrows[borrower]
。其中 principal
是用户借款总数,interestIndex
为利率指数,这东西很重要,用于计算利息。最后调用 doTransferOut
转账到用户。
还款函数
同样通过 borrowBalanceStoredInternal
计算用户的借款总数 accountBorrowsPrev
(包括利息),然后更新 accountBorrows[borrower]。
可以看到这些操作除去利率计算和审计校验还是很简单,就跟银行存款借款一样。其中存款人的利润和借款人的利息比较重要,其涉及的函数 exchangeRateStoredInternal
、borrowBalanceStoredInternal
,我看了好久才知道利润和利息是如何计算的,这个结合了后面的 accrueInterest
再讲。下面继续看利率模型合约。
利率模型 Compound的利率模型有两种:直线型和拐点型。
其中,黑色线为横轴,表示利用率,紫色线表示借款利率,绿色线表示存款利率。
可以看到借款利率的变化都是线性的,符合公式 y = k * x
,而存款利率是根据借款利率计算的,后面会介绍如何计算。
利率模型的建立,本质上是要反映借贷供求关系的一个量化指标,利用率低说明存款的多,但借款的太少,即供给大于需求。这时候就需要鼓励用户多借款少存款,所以借款利率偏低,存款利率也偏低。 而利用率高的时候则相反,借款利率和存款利率偏高,就能鼓励大家多存款少借款。
利用率过高的话,那说明资金池里剩下的钱就比较少了,会面临资金池枯竭的风险。资金池枯竭的话,那存款用户就没资金可取,也没资金可借,这就可能会导致系统性风险了。一般资金使用率在 80% 以内比较安全。
而为了控制利用率在安全范围内,Compound 大多数抵押品都使用拐点型利率模型。对应合约为 BaseJumpRateModelV2。我们下面分析它。
状态变量
合约内定义了几个状态变量,含义如下:
BASE
: 小数点位数,用于计算。owner
: owner,这里注释说一般是Timelock合约,因为根据治理流程,提案要提交到时间锁中进行公示,一定时间后再由时间锁合约进行执行。blocksPerYear
: 一年的区块数,用于计算年利率。multiplierPerBlock
: 公式 y = k * x
中的斜率 k 值baseRatePerBlock
: 利率初始值jumpMultiplierPerBlock
: 拐点后的斜率 k 值kink
: 拐点值,也就是资金利用率到了多少就拐点了,比如是80%。获取区块借款利率
要明确的是,我们目前要讨论的利率都是区块利率。我们以CToken中的borrowRatePerBlock
、supplyRatePerBlock
函数为入口。
然后进入到BaseJumpRateModelV2中:
借款利率的计算很简单。首先使用 utilizationRate
计算资金利用率:
function utilizationRate(uint cash, uint borrows, uint reserves) public pure returns (uint) {
// Utilization rate is 0 when there are no borrows
if (borrows == 0) {
return 0;
}
return borrows * BASE / (cash + borrows - reserves);
}
然后进行判断,如果资金使用率没超过拐点,借款利率 y = x * k + b
。
以UDSC为例,在其 cToken 合约 查询到 cash、borrows、reserves数据后,去到其 利率模型合约 得到的利用率和页面一致:
再查询到的参数和利用率代入 y = x * k + b
得:
562830831443407075 * 23782343987 / 1e18 + 0 = 13385436439.876324
与CToken中的borrowRatePerBlock
函数查询返回一致。
注意,这里一开始就说了,这里计算的是区块利率,那么如何计算年利率APY呢?根据官网的描述,一天中的区块利润是单利的,而一年中的365天是复利的。也就是说一天单利积累下来的利润,第二天才产生复利。
一开始,我按照合约中一年2102400个区块,一天5760个区块进行计算得出:
(13385436439 / 1e18 * 5760 + 1) ** 365 - 1
= 0.0285401396991547
明显和页面中显示的3.5%对不上。 后按照一年2628000个区块,一天7200个区块进行计算得出:
(13385436439 / 1e18 * 7200 + 1) ** 365 - 1
0.03580119839257878
上面反映了两个问题: 1.官网的计算和合约的APR计算不一致,一年中挖出的区块数量确实也是不确定,只能算个大概的APR值。 2.按照官网的方法计算,和页面展示的还是不一致,有差距。这里我去翻阅前端站点代码,奈何其使用的是神奇的 elm,也没找到到底是如何计算的。
不过无妨,只要区块利率是正确的就好,APR只是个参考值。 接着代码分析,如果资金利用率超过了拐点,即将拐点前后两部分分别计算后相加即可。
获取区块存款利率
function getSupplyRate(uint cash, uint borrows, uint reserves, uint reserveFactorMantissa) virtual override 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;
}
存款利率是根据借款利率计算的,上面代码翻译为:存款利率 = 资金使用率 借款利率 (1 - 储备金率)。简单来说就是借款利息的一部分要分到储备金里进行储备。
accrueInterest、exchangeRateStoredInternal、borrowBalanceStoredInternal函数
在之前CToken合约中,由上面借款利率和存款利率可知,其计算是基于 totalCash、totalBorrows、totalReserves、totalSupply的。因此,accrueInterest
要求在引起这几个变量变化的地方调用,也就是前面的mint、borrow等操作。并且,只要更新了这几个变量,基于其计算的利率自然而然也就得到了更新。
比较重要的是以下计算:
Exp memory simpleInterestFactor = mul_(Exp({mantissa: borrowRateMantissa}), blockDelta);
uint interestAccumulated = mul_ScalarTruncate(simpleInterestFactor, borrowsPrior);
uint totalBorrowsNew = interestAccumulated + borrowsPrior;
uint totalReservesNew = mul_ScalarTruncateAddUInt(Exp({mantissa: reserveFactorMantissa}), interestAccumulated, reservesPrior);
uint borrowIndexNew = mul_ScalarTruncateAddUInt(simpleInterestFactor, borrowIndexPrior, borrowIndexPrior);
解释如下:
simpleInterestFactor = borrowRate * blockDelta // 计算区块数量时间的利息率
interestAccumulated = simpleInterestFactor * totalBorrows // 计算要收的利息
totalBorrowsNew = interestAccumulated + totalBorrows // 总借款
totalReservesNew = interestAccumulated * reserveFactor + totalReserves // 总储备
borrowIndexNew = simpleInterestFactor * borrowIndex + borrowIndex // 累加借款指数
这里重点来了,前面提到 exchangeRateStoredInternal
、borrowBalanceStoredInternal
函数中会处理存款人的利润和借款人的利息。我算了这么久的利率,这两个函数里面该TMD用上了吧。
结果??:
exchangeRateStoredInternal
、borrowBalanceStoredInternal
中在用户操作时进行单独的单利或复利的计算。实际的利润和利息计算:在 accrueInterest
中对 totalBorrows
、borrowIndex
进行更新,一般这两个值会越来越大,相当于对全部借款进行收利息计算。具体在 exchangeRateStoredInternal
、borrowBalanceStoredInternal
函数中的使用,就是使汇率越来越高,取款时cToken可以换取更多的抵押品,留下的为利润;使borrowIndex越大,还款时将还款数算大,多出要还的为利息。
某种程度上来说,前面计算那么久的利率,和利润、利息没有直接关系。我也不知道这两种收利息的方式是否有着某些数学计算而严格的相等,最后用户分配到的利息是精确的。还是说,其整个存储借贷模型本来就是模糊的精确。
限于篇幅,有些模块后续再写了。审核合约、清算流程、价格预言机等。 总的来说代码看不起来不多,理解并写起来要花不少时间。
Compound v2 docs Compound白皮书 剖析DeFi借贷产品之Compound:合约篇 Compound Dapp-Learning https://github.com/qdwds/compoundV2 https://www.bilibili.com/video/BV1RP4y157P1/
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!