【源码】Compound V2 合约源码解析

  • ceido
  • 更新于 2023-03-05 20:53
  • 阅读 3779

前言在网上看了不少Compound的源码资料,但脑子始终感觉有点混乱,不如自己也再梳理总结一下,作为自己的学习记录。准备本次看的是CompoundV2的版本,而V3版本于2022年8月26日上线的以太坊主网。V2版本源码阅读门槛低一下,思想也类似,理解了V2再去看V3应该会更容易。

前言

在网上看了不少Compound的源码资料,但脑子始终感觉有点混乱,不如自己也再梳理总结一下,作为自己的学习记录。

准备

本次看的是Compound V2的版本,而V3版本于2022年8月26日上线的以太坊主网。V2版本源码阅读门槛低一下,思想也类似,理解了V2再去看V3应该会更容易。关于V3的升级点,可以看这篇 compound v2 v3 对比

下面是一些准备工作:

  • Compound v2仓库:要阅读的源码仓库。而V3源码为另一个仓库
  • Compound v2站点:关于页面上的一些利率数据,后面会将合约上的计算与其对应。
  • Compound v2站点仓库:我找了半天,才大概确定这个是V2的前端站点仓库,与主流的React Next.js框架相比,其使用的是一种叫 elm 的函数式编程语言来编写页面。
  • Compound v2 docs:v2文档,主要看各个合约的地址。

正文

1. Compound站点功能介绍

页面主要有两个,一个是Dashboard、一个是Markets页面。站点有点体验不好的是,不支持切换连接Goerli等测试网,所以这里我也无法演示了。而Aave的站点是可以让你连接测试网的进行玩玩的。

Dashboard页面

下面是页面的一些信息:

  • Supply Balance:存款量,折算为以USD为单位。
  • Borrow Balance:借款量,折算为以USD为单位。
  • APY:存款或借款的年利率,为复利利率。
  • Borrow Markets 的 Liquidity:流动性,一会会说是怎么计算的。

点击 Collateral 下的开关按钮,会有一个弹窗确认是否要将你的存款作为抵押品: image.png 这里开启货币作为抵押品后,就可以借入其他数字资产了。例如,可以将 ETH 作为抵押品来借取 Dai。

点击Supply Martets中的某个币种,会有一个弹窗,可以进行Supply和Withdraw操作: image.png

点击Borrow Martets中的某个币种,可以进行Borrow(借款)和Repay(还款)操作: image.png

Markets页面

image.png

下面是市场页面的一些市场信息,以Dai为例:

  • Total Earning:总存款量。
  • Total Borrowing:总借款量。
  • Collateral Factor:抵押因子(该币种的最大抵押率),为 借款价值 / 抵押价值。比如你提供了价值 100USDT的Dai进行抵押,你最多只能借到价值83.5USDT的其他数字资产,比如 83.5USDT的USDC。(这里解释一下超额抵押的概念,就是指抵押资产的价值必须高于借入资产的价值。)
  • Earn APR:存款利率,对应Dashboard页面的数据。
  • Borrow APR:借款利率,对应Dashboard页面的数据。
  • Reserve Factor:储备因子(储备率),就是存款中的一部分钱要作为储备金,不对外借出的。
  • Oracle Price:预言机报价
  • Reserves:储备金金额,来源于利息积累的一部分
  • Borrow Cap:借出上限
  • Utilization: 资金利用率

后面我们要分析合约是如何实现存款取款、借款还款操作,以及页面上对应利率和数据的计算,我们就能大概能看懂整个项目了。

2.源码分析
2.1 项目结构

项目是使用一个叫 eth-saddle 的框架搭建的,感觉作者喜欢用一些奇奇怪怪的框架,而在V3项目已经换成Hardhat了。整个项目我们主要关注的是contracts文件夹:

image.png

合约文件虽然有点多,但主要可以分为四个模块:

  • CToken:cToken合约是主要业务逻辑的合约,其中上面页面所讲的存款取款、借款还款等逻辑都在里面。另外,CToken合约引入了 InterestRateModel 合约和 Comptroller 合约,因此先阅读改相关合约。 其余的 CEther、CErc20Delegator代理合约随便看看就好。

那它为什么叫CToken合约,不像Aave源码一样搞个Pool、SupplyLogic等合约?首先要理解的是 CToken 概念,用户在存款后,要给用户一个凭证,早期的简单实现是直接在合约里搞个mapping记录用户的存款余额之类的,而现在代币化的好处是更具象化,并且作为代币的凭证也可以更好的转让或质押。 每种抵押品都会部署其对应其cToken代币合约,如cDAI、cETH等,相关cToken地址可以在官网查看:

  • InterestRateModel:利率模型的抽象合约,JumpRateModelV2、JumpRateModel、WhitePaperInterestRateModel 都是具体的利率模型实现。
  • Comptroller:审计合约,审计两个字看起来很高大上,就是封装了一些业务检验功能,在存款取款、借款还款前进行校验,以及比如上面页面讲的开启抵押品等操作。
  • Governance:包含治理合约和治理代币Comp合约,Openzepplin也有相关实现。治理这个词也很高大上的样子,简单来说就是拥有一定量Comp代币的人可以发起提案,这些提案一般就是改改参数啥的,比如抵押因子、储备因子。
  • PriceOracle:价格预言机合约,用来获取资产价格的。

看源码我们了解整体项目后,去繁化简,阅读其核心逻辑,其余边边角角有空可以慢慢看,这里我主要阅读CTokenInterestRateModelComptroller

2.2 CToken.sol

存款、取款、借款、还款操作分别对应 CToken.sol 中的 mintInternalredeemInternalborrowInternalrepayBorrowInternal

存款函数

    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 的调用栈:

WeChat7c65e2da5bde3157fa4b7155b3071b6e.png

这几个操作前都会调用 accrueInterest 进行计算新的利息,这里后续讲 InterestRateModel 合约时再看,我们先看 mintFresh 操作。

WeChat39a15797696f083b36cc0d58601c48c5.png

可以看到mintFresh有以下操作: comptroller合约检查、汇率计算、将抵押品转入合约、根据汇率计算获得的 cToken 数量、计算 cToken totalSupply,以及用户对应的总cToken数量。

这里了解存款操作的大概流程就是将抵押品转入合约,根据汇率获取一定量的cToken。

取款函数 取款函数逻辑也差不多,就是根据 exchangeRateStoredInternal的汇率将抵押品赎回,归还一定量的cToken,利润通过汇率的变化来获取。这个函数提供了 uint redeemTokensInuint redeemAmountIn 参数,意思你可以给定抵押品量或者给定cToken量来进行取款。 image.png

借款函数 通过 borrowBalanceStoredInternal 计算用户的借款总数 accountBorrowsPrev(包含利息),加上本次借款数 borrowAmount,存到 accountBorrows[borrower]。其中 principal 是用户借款总数,interestIndex 为利率指数,这东西很重要,用于计算利息。最后调用 doTransferOut 转账到用户。

还款函数 同样通过 borrowBalanceStoredInternal 计算用户的借款总数 accountBorrowsPrev(包括利息),然后更新 accountBorrows[borrower]。

可以看到这些操作除去利率计算和审计校验还是很简单,就跟银行存款借款一样。其中存款人的利润和借款人的利息比较重要,其涉及的函数 exchangeRateStoredInternalborrowBalanceStoredInternal,我看了好久才知道利润和利息是如何计算的,这个结合了后面的 accrueInterest 再讲。下面继续看利率模型合约。

2.3 InterestRateModel.sol

利率模型 Compound的利率模型有两种:直线型拐点型

WeChat2ebc337476d0e5f1228ca6b07dc4915f.png

WeChatdafde64012cee1004b290030a7c0c1c6.png

其中,黑色线为横轴,表示利用率,紫色线表示借款利率,绿色线表示存款利率。 可以看到借款利率的变化都是线性的,符合公式 y = k * x,而存款利率是根据借款利率计算的,后面会介绍如何计算。

利率模型的建立,本质上是要反映借贷供求关系的一个量化指标,利用率低说明存款的多,但借款的太少,即供给大于需求。这时候就需要鼓励用户多借款少存款,所以借款利率偏低,存款利率也偏低。 而利用率高的时候则相反,借款利率和存款利率偏高,就能鼓励大家多存款少借款。

利用率过高的话,那说明资金池里剩下的钱就比较少了,会面临资金池枯竭的风险。资金池枯竭的话,那存款用户就没资金可取,也没资金可借,这就可能会导致系统性风险了。一般资金使用率在 80% 以内比较安全。

而为了控制利用率在安全范围内,Compound 大多数抵押品都使用拐点型利率模型。对应合约为 BaseJumpRateModelV2。我们下面分析它。

状态变量 WeChatd09c18d37db870c6030ccc6ad8bed3aa.png

合约内定义了几个状态变量,含义如下:

  • BASE: 小数点位数,用于计算。
  • owner: owner,这里注释说一般是Timelock合约,因为根据治理流程,提案要提交到时间锁中进行公示,一定时间后再由时间锁合约进行执行。
  • blocksPerYear: 一年的区块数,用于计算年利率。
  • multiplierPerBlock: 公式 y = k * x 中的斜率 k 值
  • baseRatePerBlock: 利率初始值
  • jumpMultiplierPerBlock: 拐点后的斜率 k 值
  • kink: 拐点值,也就是资金利用率到了多少就拐点了,比如是80%。

获取区块借款利率 要明确的是,我们目前要讨论的利率都是区块利率。我们以CToken中的borrowRatePerBlocksupplyRatePerBlock函数为入口。

然后进入到BaseJumpRateModelV2中:

WeChat1d01d66188aa207f111ba847c58f666e.png

WeChatb2ffd182b093f6447d86b3dad676582d.png

借款利率的计算很简单。首先使用 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数据后,去到其 利率模型合约 得到的利用率和页面一致:

WeChatdafde64012cee1004b290030a7c0c1c6.png

WeChatb1448dbe3fd575fddac08e40f2185670.png

再查询到的参数和利用率代入 y = x * k + b 得:

562830831443407075 * 23782343987 / 1e18 + 0 = 13385436439.876324

与CToken中的borrowRatePerBlock函数查询返回一致。

WeChat110f58178acc525e5a1c3c79658225cf.png 注意,这里一开始就说了,这里计算的是区块利率,那么如何计算年利率APY呢?根据官网的描述,一天中的区块利润是单利的,而一年中的365天是复利的。也就是说一天单利积累下来的利润,第二天才产生复利。

WeChata7b069c6fdbbc5f568a64ea293610e32.png

一开始,我按照合约中一年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等操作。并且,只要更新了这几个变量,基于其计算的利率自然而然也就得到了更新。 WeChat84fb529fd2e414630097f05f833fc82b.png

比较重要的是以下计算:

        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 // 累加借款指数

这里重点来了,前面提到 exchangeRateStoredInternalborrowBalanceStoredInternal 函数中会处理存款人的利润和借款人的利息。我算了这么久的利率,这两个函数里面该TMD用上了吧。

结果??: WeChat8bee2658142514ad2d023582845bf204.png

WeChat23c636ae31030e797ef9fe0d579040d8.png

  • 我理解的利润和利息计算:根据区块利率或区块时间在exchangeRateStoredInternalborrowBalanceStoredInternal中在用户操作时进行单独的单利或复利的计算。
  • 实际的利润和利息计算:在 accrueInterest 中对 totalBorrowsborrowIndex 进行更新,一般这两个值会越来越大,相当于对全部借款进行收利息计算。具体在 exchangeRateStoredInternalborrowBalanceStoredInternal函数中的使用,就是使汇率越来越高,取款时cToken可以换取更多的抵押品,留下的为利润;使borrowIndex越大,还款时将还款数算大,多出要还的为利息。

    某种程度上来说,前面计算那么久的利率,和利润、利息没有直接关系。我也不知道这两种收利息的方式是否有着某些数学计算而严格的相等,最后用户分配到的利息是精确的。还是说,其整个存储借贷模型本来就是模糊的精确。

4.其他部分 & 总结

限于篇幅,有些模块后续再写了。审核合约、清算流程、价格预言机等。 总的来说代码看不起来不多,理解并写起来要花不少时间。

参考

Compound v2 docs Compound白皮书 剖析DeFi借贷产品之Compound:合约篇 Compound Dapp-Learning https://github.com/qdwds/compoundV2 https://www.bilibili.com/video/BV1RP4y157P1/

  • 原创
  • 学分: 0
  • 分类: DeFi
  • 标签:
点赞 0
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

3 条评论

请先 登录 后评论
ceido
ceido
0x6903...4C2b
江湖只有他的大名,没有他的介绍。