注册
关闭
区块链智慧谷

区块链智慧谷

发布于 2021-07-28 阅读数 6528

DeFi 研究之Compound 源码精讲

通过前两篇文章,我们对 Compound 已经有了相当的了解。作为一个完整的借贷项目,Compound 涉及到非常多的内容,但是对于区块链项目来说,智能是其灵魂所在,今天这篇文章我们就从合约的角度来分析下 Compound。 闲话少说,进入正题。

Compound 合约简介

Compound 合约 源代码是公开的,每个人都可以去下载、运行。我们可以看到 Compound 源代码还是比较多的,这里我们首先介绍一些比较核心的合约:

  • CToken, CErc20 和 CEther
    Compound cTokens 是一个自包含的借贷合约。CToken 包含核心逻辑,CErc20 和 CEther 分别为 Erc20 Token 和以太坊添加了公共接口。每个 CToken 都被分配一个利率和风险模型(见 InterestRateModelComptroller 部分),并允许账户铸造(供应资本)、赎回(撤回资本)、借入和偿还借款。每个 CToken 都是符合 ERC-20 标准的 token,其中余额代表市场所有权。
  • 审计
    风险模型契约,它验证允许的用户操作,如果操作不符合特定的风险参数,则禁止操作。例如,Comptroller 强制每个借贷用户必须在所有ctoken 上保持足够的担保余额。
  • 复合治理令牌
    复合治理令牌(COMP) 是 Compound Governance Token 的缩写。该代币的持有者有能力通过 governor 合约来管理协议。
  • Governor Alpha
    Compound 时间锁合约的管理员。Comp 代币持有人可以创建提案并对提案进行投票,提案将排队进入 Compound 时间锁,然后对 Compound cToken 和 Comptroller 合约产生影响。这个合同将来可能会被测试版所取代。
  • InterestRateModel
    定义利率模型的合约。这些模型根据给定市场的当前利用情况(即,相对于借入的资产,有多少是流动性的),用算法来确定利率。
  • 安全数学
    安全的数学操作库。
  • ErrorReporter
    跟踪错误代码和故障条件的库。
  • 指数
    处理定点十进制数的库。
  • SafeToken
    安全处理 Erc20 交互的库。
  • WhitePaperInterestRateModel
    初始利率模型,如白皮书中所定义。该合约在其构造函数中接受一个基本利率和斜率参数。

CToken, CErc20 和 CEther

作为 Compound 合约的核心,我们首先来看下这几个合约。

CToken 主要继承自 CTokenInterface 这个合约,当然它也同时继承了 ExponentialTokenErrorReporter 这两个合约,后两个合约分别用来处理定点十进制数和错误报告相关,这里我们就不细讲。

CTokenInterface 合约本质上是一个抽象合约,只是由于定义了一个常量所以才没有声明为抽象合约。这个合约声明了货币市场的事件、管理事件、用户操作、管理员操作

CTokenInterface 合约继承自 CTokenStorage 合约,后者定于了大量的状态变量。CTokenInterfaceCTokenStorage 两个合约一个定义了主要操作,一个定义了主要的状态,实现了数据与逻辑的分离,从而可以保证 CToken 合约可升级。当使用代理合约模式后,用户对目标合约的所有调用都通过代理合约,代理合约会将调用请求重定向到目标合约中。具体调用处理如下图:

DeFi 研究之Compound 源码精讲

代理合约

CToken 是一个抽象的基础合约,没有构造函数,所以不能被部署,只能被别的合约继承,继承这个合约的正是 CErc20CEther。其中,CEther 用来以太坊代币,它有构造函数,所以可以被部署在链上,正是用户交互的入口合约之一,而 CErc20 用来处理基于 ERC20 标准的其他代币,但是这个合约没有提供构造函数,只有一个初始化函数,说明它是不可以被部署的。

那么如何部署 CErc20 合约呢,答案正是代理模式。通过浏览代码,我们可以看到与 CErc20 相关的合约还有两个,分别是 CErc20DelegateCErc20DelegatorCErc20Delegator 对应于图中的 Proxy Contract,而 CErc20Delegate 则对应于 Logic Contract,这两个合约都是可以部署的,CErc20Delegator 也是用户交互的另一个入口合约。

以太坊货币市场

构造 ETH 货币市场

如上所述,ETH 的 cToken 交互入口是 CEther 合约,它的构造方法如下:

constructor(ComptrollerInterface comptroller_,
            InterestRateModel interestRateModel_,
            uint initialExchangeRateMantissa_,
            string memory name_,
            string memory symbol_,
            uint8 decimals_,
            address payable admin_) public {

    admin = msg.sender;
    initialize(comptroller_, interestRateModel_, initialExchangeRateMantissa_, name_, symbol_, decimals_);
    admin = admin_;
}

这段代码非常简单,首先设置管理员地址为当前合约调用者即部署者地址,然后调用父合约的 initialize 函数进行初始化,最后初始完成后,重新设置管理员地址为参数指定的地址。

初始化函数 initialize 流程如下:

  1. 检查函数调用查为管理员地址,否则抛出异常。
    require(msg.sender == admin, "only admin may initialize the market");
  2. 检查两个变量是否为0,如果不是则抛出异常。这一步主要是为了确保只初始化一次。
    require(accrualBlockNumber == 0 && borrowIndex == 0, "market may only be initialized once");
  3. 设置初始的兑换率为参数指定的值,并进行检查以确保大于0。
    initialExchangeRateMantissa = initialExchangeRateMantissa_;
    require(initialExchangeRateMantissa > 0, "initial exchange rate must be greater than zero.");
  4. 调用 _setComptroller 函数,设置审计合约地址。
    uint err = _setComptroller(comptroller_);
    require(err == uint(Error.NO_ERROR), "setting comptroller failed");

    _setComptroller 函数首先检查调用者为管理员,然后保存当前审核合约地址到一个临时变量中,并检查新的地址为审计合约,然后保存新的审计合约到审计合约状态变量中,最后发射 NewComptroller 事件。

  5. 初始化应计息区块号和 borrow index
    accrualBlockNumber = getBlockNumber();
    borrowIndex = mantissaOne;

    getBlockNumber 函数直接调用 block.number 返回当前的区块号码,mantissaOne 为一个 1e18 常量。

  6. 设置利率模型。
    err = _setInterestRateModelFresh(interestRateModel_);
    require(err == uint(Error.NO_ERROR), "setting interest rate model failed");

    _setInterestRateModelFresh 函数首先检查调用者为管理员,应计息区块号和当前区块号相同(确保是在同一个区块内进行设置的),然后保存当前利率模型合约地址到一个临时变量中,并检查新的地址为利率模型合约,然后保存新的利率模型合约到利率模型合约状态变量中,最后发射 NewMarketInterestRateModel 事件。

  7. 最后,设置新 Token 的名称、符号、小数位等。
    name = name_;
    symbol = symbol_;
    decimals = decimals_;

    _notEntered = true;

    在 Compound 货币市场中,为每个支持的代币都会生成一个新的 cToken,这个新的 cToken 是一个兼容的 ERC20 代币,所以也会有名称、符号、小数位等。

存款 `mint`

存款函数会新增 cToken 数量,即 totalSupply 增加了,就等于挖矿了 cToken。该操作会同时将用户的标的资产转入 cToken 合约中(数据会存储在代理合约中),并根据最新的兑换率将对应的 cToken 代币转到用户钱包地址。

CEther 的存储函数非常简单,简单到只有一行代码,代码如下:

(uint err,) = mintInternal(msg.value);

因为存款函数是直接调用父函数的 mintInternal 函数,那我们就进入这个函数一看究竟。

mintInternal 函数执行流程如下:

  1. 调用 accrueInterest 函数计算利息。如果出现在错误,则抛出异常。异常这里我们就不细说。
    uint error = accrueInterest();

    accrueInterest 函数流程如下:

    • 获取当前区块号和前一次调用时的区块号
      uint currentBlockNumber = getBlockNumber();
      uint accrualBlockNumberPrior = accrualBlockNumber;
    • 如果当前区块号和前一次调用时的区块号一样,表示当前区块已经计算过利息,无需再计算,直接返回。
      if (accrualBlockNumberPrior == currentBlockNumber) {
      return uint(Error.NO_ERROR);
      }
    • 获取资金池余额、总借款、总储备金、借款指数。
      uint cashPrior = getCashPrior();
      uint borrowsPrior = totalBorrows;
      uint reservesPrior = totalReserves;
      uint borrowIndexPrior = borrowIndex;
    • 调用利率模型合约的 getBorrowRate 方法,获取当前的借款利率。
      uint borrowRateMantissa = interestRateModel.getBorrowRate(cashPrior, borrowsPrior, reservesPrior);
    • 如果当前借款利率超过最大的借款利率,则抛出异常。
      require(borrowRateMantissa <= borrowRateMaxMantissa, "borrow rate is absurdly high");

      最大的借款利率当前设置为 0.0005e16,即每区块 0.0005%

    • 计算从上次计算之后到当前经过的区块数量。该区块数量表示还未计算利息的区块区间。
      (MathError mathErr, uint blockDelta) = subUInt(currentBlockNumber, accrualBlockNumberPrior);
    • 计算利息因子。利息因子 = 当前借款利率 * 累计未计算区块间隔。
      (mathErr, simpleInterestFactor) = mulScalar(Exp({mantissa: borrowRateMantissa}), blockDelta);
    • 计算应计利息。应计利息 = 利息因子 * 当前借款总额。
      (mathErr, interestAccumulated) = mulScalarTruncate(simpleInterestFactor, borrowsPrior);
    • 计算借款总额。借款总额 = 应计利息 + 当前借款总额
      (mathErr, totalBorrowsNew) = addUInt(interestAccumulated, borrowsPrior);
    • 计算储备金总额。储备金总额 = 储备因子 * 应计利息 + 当前的储备金总额。
      (mathErr, totalReservesNew) = mulScalarTruncateAddUInt(Exp({mantissa: reserveFactorMantissa}), interestAccumulated, reservesPrior);
    • 计算借款指数。借款指数 = 利息因子 * 当前借款指数 + 当前借款指数.
      (mathErr, borrowIndexNew) = mulScalarTruncateAddUInt(simpleInterestFactor, borrowIndexPrior, borrowIndexPrior);
    • 更新当前区块高度、借款指数、借款总额、储备金总额。
      accrualBlockNumber = currentBlockNumber;
      borrowIndex = borrowIndexNew;
      totalBorrows = totalBorrowsNew;
      totalReserves = totalReservesNew;
    • 发射利息事件。
      AccrueInterest
  2. 触发相应的事件。
    return mintFresh(msg.sender, mintAmount);

    mintFresh 函数执行流程如下:

    • 调用审计合约的 mintAllowed 方法,检查是否允许当前地址存入指定数量的 ETH。如果不允许,则抛出异常。
      uint allowed = comptroller.mintAllowed(address(this), minter, mintAmount);
    • 验证当前区块高度是否等于市场保存的区块高度。如果不一致则抛出异常。
      if (accrualBlockNumber != getBlockNumber()) {
      return (fail(Error.MARKET_NOT_FRESH, FailureInfo.MINT_FRESHNESS_CHECK), 0);
      }
    • 计算当前的兑换率。
      MintLocalVars memory vars;
      (vars.mathErr, vars.exchangeRateMantissa) = exchangeRateStoredInternal();

      exchangeRateStoredInternal 函数,首先检查当前总的供给量是否为0,如果是返回初始的兑换率,否则使用 (totalCash + totalBorrows - totalReserves) / totalSupply 计算新的的兑换率。

    • 计算发起者实际转入到合约中的 Token 数量。
      vars.actualMintAmount = doTransferIn(minter, mintAmount);

      对于当前情况,即底层资产为 ETH,实际输入的数量为 100%,即输入的数量为 mintAmount

    • 计算需要铸造的 cToken 数量。
      (vars.mathErr, vars.mintTokens) = divScalarByExpTruncate(vars.actualMintAmount, Exp({mantissa: vars.exchangeRateMantissa}));

      铸造的 cToken 数量等于真实输入到合约中的 Token 数量除以兑换率,公式如下:mintTokens = actualMintAmount / exchangeRate

    • 把铸造出来的 cToken 数量加入到总供给中。
      (vars.mathErr, vars.totalSupplyNew) = addUInt(totalSupply, vars.mintTokens);
    • 把铸造出来的 cToken 数量加入到用户对应的账户中。
      (vars.mathErr, vars.accountTokensNew) = addUInt(accountTokens[minter], vars.mintTokens);
    • 更新合约的总供给量和用户对应的数量。
      totalSupply = vars.totalSupplyNew;
      accountTokens[minter] = vars.accountTokensNew;
    • 发射铸造事件和转账事件。
      emit Mint(minter, vars.actualMintAmount, vars.mintTokens);
      emit Transfer(address(this), minter, vars.mintTokens);

赎回存款 `redeem`

赎回存款函数同样只有一行调用父合约的代码:

return redeemInternal(redeemTokens);

redeemInternal 函数执行如下:

  1. 调用 accrueInterest 函数计算利息。如果出现在错误,则抛出异常。异常这里我们就不细说。
    uint error = accrueInterest();

    accrueInterest 这个函数前面已经过分析过,这里略过。

  2. 调用 redeemFresh 函数,赎回存款。
    return redeemFresh(msg.sender, redeemTokens, 0);

    redeemFresh 函数流程如下:

    • 检查赎回的 Token 数量或购回的底层资产数量是否为0。如果是,则抛出异常。
      require(redeemTokensIn == 0 || redeemAmountIn == 0, "one of redeemTokensIn or redeemAmountIn must be zero");
    • 计算当前的兑换率。
      RedeemLocalVars memory vars;
      (vars.mathErr, vars.exchangeRateMantissa) = exchangeRateStoredInternal();

      exchangeRateStoredInternal 函数前面已经解释过,这里略过不提。

    • 如果赎回的 Token 数量大于0,则计算对应的 cToken 数量。
      vars.redeemTokens = redeemTokensIn;
      (vars.mathErr, vars.redeemAmount) = mulScalarTruncate(Exp({mantissa: vars.exchangeRateMantissa}), redeemTokensIn);

      具体计算公式等于 redeemTokensIn x exchangeRateCurrent

    • 如果赎回的底层资产数量大于0,则计算对应的 cToken 数量。
      (vars.mathErr, vars.redeemTokens) = divScalarByExpTruncate(redeemAmountIn, Exp({mantissa: vars.exchangeRateMantissa}));

      vars.redeemAmount = redeemAmountIn;

      具体计算公式等于 redeemAmountIn / exchangeRateCurrent

    • 调用审计合约的 redeemAllowed 方法,检查是否允许赎回存款。如果不允许则抛出异常。
      uint allowed = comptroller.redeemAllowed(address(this), redeemer, vars.redeemTokens);
    • 验证当前区块高度是否等于市场保存的区块高度。如果不一致则抛出异常。
    • 计算用户赎回存款之后,新的供给总量和用户新的余额。
      (vars.mathErr, vars.totalSupplyNew) = subUInt(totalSupply, vars.redeemTokens);
      (vars.mathErr, vars.accountTokensNew) = subUInt(accountTokens[redeemer], vars.redeemTokens);
    • 如果用户赎回的数量大于当前合约的现金,则抛出异常。因为合约没有足够的资产可供用户赎回。
      if (getCashPrior() < vars.redeemAmount) {
      return fail(Error.TOKEN_INSUFFICIENT_CASH, FailureInfo.REDEEM_TRANSFER_OUT_NOT_POSSIBLE);
      }
    • 从合约中转出指定数量的资产到用户地址。
      doTransferOut(redeemer, vars.redeemAmount);
    • 更新合约的总供给量和用户对应的数量。
      totalSupply = vars.totalSupplyNew;
      accountTokens[redeemer] = vars.accountTokensNew;
    • 发射转账事件和赎回事件。
      emit Transfer(redeemer, address(this), vars.redeemTokens);
      emit Redeem(redeemer, vars.redeemAmount, vars.redeemTokens);
    • 调用审计合约的 redeemVerify 方法进行后续处理。
      comptroller.redeemVerify(address(this), redeemer, vars.redeemAmount, vars.redeemTokens);

除了上面这种赎回方式,Compound 还支持指定赎回指定数量的底层资产的方式,两种方式在代码层面只有稍微不同,读者可以自行进行分析。

借款 `borrow`

借款函数也只有一行代码:

return borrowInternal(borrowAmount);

borrowInternal 函数定义在父合约中,它的执行如下:

  1. 同样是,调用 accrueInterest 函数计算利息。如果出现在错误,则抛出异常。异常这里我们就不细说。
    uint error = accrueInterest();

accrueInterest 这个函数前面已经过分析过,这里略过。

  1. 调用 borrowFresh 函数,进行贷款处理。
    borrowFresh 函数内容如下:
    • 调用审计合约的 borrowAllowed 方法,检查是否允许调用查贷款指定数量。如果不允许,则抛出异常。
      uint allowed = comptroller.borrowAllowed(address(this), borrower, borrowAmount);
    • 验证当前区块高度是否等于市场保存的区块高度。如果不一致则抛出异常。
    • 检查借款数量是否大于合约中的现金。如果是则抛出异常。
      if (getCashPrior() < borrowAmount) {
      return fail(Error.TOKEN_INSUFFICIENT_CASH, FailureInfo.BORROW_CASH_NOT_AVAILABLE);
      }
    • 计算账户借款总额。
      BorrowLocalVars memory vars;
      (vars.mathErr, vars.accountBorrows) = borrowBalanceStoredInternal(borrower);

      borrowBalanceStoredInternal 函数返回几遍的贷款总额,具体流程如下:

      • 获取包含账户借款余额和借款指数信息的借款快照。
        BorrowSnapshot storage borrowSnapshot = accountBorrows[account];
      • 如果用户已借款数量为0,则立即返回。
        if (borrowSnapshot.principal == 0) {
        return (MathError.NO_ERROR, 0);
        }
      • 计算用户最新的借款金额,并返回借款余额。
        (mathErr, principalTimesIndex) = mulUInt(borrowSnapshot.principal, borrowIndex);
        (mathErr, result) = divUInt(principalTimesIndex, borrowSnapshot.interestIndex);
        return (MathError.NO_ERROR, result);

        用户最新的借款金额等于 用户的借款金额 * 市场借款指数 / 用户的借款指数

    • 计算用户新的借款金额和市场总的贷款金额,包括当前这一次借款金额。
      (vars.mathErr, vars.accountBorrowsNew) = addUInt(vars.accountBorrows, borrowAmount);
      (vars.mathErr, vars.totalBorrowsNew) = addUInt(totalBorrows, borrowAmount);
    • 从货币市场转入指定金额到用户地址。
      doTransferOut(borrower, borrowAmount);
    • 更新用户的借款金额和借款指数以及市场总的借款金额。
      accountBorrows[borrower].principal = vars.accountBorrowsNew;
      accountBorrows[borrower].interestIndex = borrowIndex;
      totalBorrows = vars.totalBorrowsNew;
    • 发射借款事件。
      emit Borrow(borrower, borrowAmount, vars.accountBorrowsNew, vars.totalBorrowsNew);

还款 `repayBorrow`

还款函数也是直接调用父合约的 repayBorrowInternal 方法进行还款,这个函数也比较简单,代码如下:

uint error = accrueInterest();

return repayBorrowFresh(msg.sender, msg.sender, repayAmount);

accrueInterest 函数我们已经见过很多次,不用管,重点看下 repayBorrowFresh 函数,它的流程如下:

  1. 调用审计合约的 repayBorrowAllowed 方法,检查用户是否被允许还款。如果不允许则抛出异常。
    uint allowed = comptroller.repayBorrowAllowed(address(this), payer, borrower, repayAmount);
  2. 验证当前区块高度是否等于市场保存的区块高度。如果不一致则抛出异常。
  3. 获取用户的借款指数。
    RepayBorrowLocalVars memory vars;
    vars.borrowerIndex = accountBorrows[borrower].interestIndex;
  4. 计算用户的借款金额,包括利息。
    (vars.mathErr, vars.accountBorrows) = borrowBalanceStoredInternal(borrower);

    borrowBalanceStoredInternal 函数前面已经分析,这里不讲。

  5. 如果还款参数为-1,则还款全部借款,否则偿还部分金额。
    if (repayAmount == uint(-1)) {
        vars.repayAmount = vars.accountBorrows;
    } else {
        vars.repayAmount = repayAmount;
    }
  6. 从还款地址中转入指定金额到货币市场。
    vars.actualRepayAmount = doTransferIn(payer, vars.repayAmount);
  7. 计算用户新的借款金额和市场总的借款金额。
    (vars.mathErr, vars.accountBorrowsNew) = subUInt(vars.accountBorrows, vars.actualRepayAmount);
    (vars.mathErr, vars.totalBorrowsNew) = subUInt(totalBorrows, vars.actualRepayAmount);
  8. 更新用户的借款金额和借款指数以及市场总的借款金额。
    accountBorrows[borrower].principal = vars.accountBorrowsNew;
    accountBorrows[borrower].interestIndex = borrowIndex;
    totalBorrows = vars.totalBorrowsNew;
  9. 发射还款事件。
    emit RepayBorrow(payer, borrower, vars.actualRepayAmount, vars.accountBorrowsNew, vars.totalBorrowsNew);

在 Compound 中,除了直接还款外,还有一个代还款函数,即调用者帮借款人还款,这个函数就是 repayBorrowBehalf,有兴趣的朋友可以自己看。

除了上面这些功能,Compound 货币市场还提供了清算功能,具体是由 liquidateBorrow 函数实现的,限于篇幅限制,这个函数就留给读者自己分析。

ERC20 货币市场

在以太坊区块链中,除了原生代币以太坊外,最常见的就是基于 ERC20 规则的代币,这些代币在 Compound 货币市场中,通过 CErc20 合约来进行支持。Coumpound 会为每个 ERC20 代币部署一个货币市场,同时会生成相应的 cToken,比如 DAI 对应的 cToken 就叫 cDAI。

正如我们前面所说的那样,ERC20 货币市场采用的是代理模式,以此方便某个 ERC20 代币的升级。代理模式以后再分析,这里我们只看底层的 CErc20 合约。

它的初始化函数如下:

function initialize(address underlying_,
                    ComptrollerInterface comptroller_,
                    InterestRateModel interestRateModel_,
                    uint initialExchangeRateMantissa_,
                    string memory name_,
                    string memory symbol_,
                    uint8 decimals_) public {
    // CToken initialize does the bulk of the work
    super.initialize(comptroller_, interestRateModel_, initialExchangeRateMantissa_, name_, symbol_, decimals_);

    // Set underlying and sanity check it
    underlying = underlying_;
    EIP20Interface(underlying).totalSupply();
}

初始化函数首先调用父合约的初始化函数进行初始化设置,然后保存对应的底层基础代币,最后实例这个底层代币,并获取它的余额来检查底层代币是否OK。

接下来照例是一些用户接口函数,因为这些函数也是调用父合约的函数,所以这次我们就不一一展示这些函数了。

利率合约

看完了,cToken 合约,接下来我们就来看下利率相关的合约。

Compound 中利率模型定义在抽象合约 InterestRateModel 中,这个合约因为是抽象合约,所以非常的简单,只定义了2个函数和一个常量,具体的代码如下:

contract InterestRateModel {

    bool public constant isInterestRateModel = true;

    function getBorrowRate(uint cash, uint borrows, uint reserves) external view returns (uint);

    function getSupplyRate(uint cash, uint borrows, uint reserves, uint reserveFactorMantissa) external view returns (uint);

}

getBorrowRate 函数用于计算每个区块的当前借款利率,getSupplyRate 函数用于计算每个区块的当前存储利率。

因为 InterestRateModel 是抽象合约,针对不同的利率实现分散在不同的具体合约中,下面我们就来看这些具体利率模型。

直线型利率模型

WhitePaperInterestRateModel 是一种最简单的利率模型,这种模型其实就是一种直线,可以称为直线型利率模型。这种模型用数学公式表示就是

y = k*x + b

其中,k 为斜率,b 是 x 为 0 时的起点值,x 是自变量,在这里表示资金利用率,y 是因变量,表示借款利率。

我们首先就来看这种利率模型。

构造利率模型

WhitePaperInterestRateModel 这种利率在构造时候要指定基础的年化利率和利率的增长速度。

构造函数的具体代码如下:

constructor(uint baseRatePerYear, uint multiplierPerYear) public {
    baseRatePerBlock = baseRatePerYear.div(blocksPerYear);
    multiplierPerBlock = multiplierPerYear.div(blocksPerYear);

    emit NewInterestParams(baseRatePerBlock, multiplierPerBlock);
}

在上面的参数中:

  • baseRatePerYear 表示基础的年化利率,对应的 baseRatePerBlock 表示区块级利率,对应公式中的截矩 b
  • multiplierPerYear 表示利率的增长速度,对应的 multiplierPerBlock 表示利率的增长速度,也就是公式中的斜率 k

基础的年化利率和利率的增长速度除以每年的区块数量得到区块级的利率和区块级的利率增长速度。

利用率

因为存储利率和贷款利率都要用到利用率,所以我们就先看下利用率这个函数。这个函数代码如下:

function utilizationRate(uint cash, uint borrows, uint reserves) public pure returns (uint) {
    if (borrows == 0) {
        return 0;
    }

    return borrows.mul(1e18).div(cash.add(borrows).sub(reserves));
}

如代码所示,如果借款金额为0时,利用率为0,否则利用率就等于 总借款 / (资金池余额 + 总借款 - 储备金),代码中 mul(1e18) 是为了保持结果的精度。

借款利率

借款利率代码如下:

function getBorrowRate(uint cash, uint borrows, uint reserves) public view returns (uint) {
    uint ur = utilizationRate(cash, borrows, reserves);
    return ur.mul(multiplierPerBlock).div(1e18).add(baseRatePerBlock);
}

首先求出利用率,然后根据直线公式 区块级的利率增长速度 * 利用率 + 区块级的基础利率 算出借款利率。这里 div(1e18) 是因为利用率和区块级的利率增长速度本身都已经扩为高精度整数了,相乘之后精度变成 36 了,所以再除以 1e18 就可以把精度降回 18。

存款利率

存款利率公式如下:

function getSupplyRate(uint cash, uint borrows, uint reserves, uint reserveFactorMantissa) public view returns (uint) {
    uint oneMinusReserveFactor = uint(1e18).sub(reserveFactorMantissa);
    uint borrowRate = getBorrowRate(cash, borrows, reserves);
    uint rateToPool = borrowRate.mul(oneMinusReserveFactor).div(1e18);
    return utilizationRate(cash, borrows, reserves).mul(rateToPool).div(1e18);
}

上面的代码整理出来就是 资金利用率 * 借款利率 * (1 - 储备金率)

直线型利率模型的内容就是这些,比较简单易懂。

拐点型利率模型

拐点型主要有过两个版本的实现,分别是 JumpRateModelJumpRateModelV2,目前,曾经使用 JumpRateModel 的都已经升级为 JumpRateModelV2,所以我们就直接研究 JumpRateModelV2 即可。

拐点型利率模型其实是是两个直线型利率的合成,在达到临界点前使用一条直接,在达到临界点之后使用另一条斜率更高的直线,只所以这么做是为了保护资金池,防止资金池中的资金被借空。

资金使用率没超过拐点值时,利率公式和直线型的一样:

y = k*x + b

而超过拐点之后,则利率公式将变成:

y = k2*(x - p) + (k*p + b)

其中,k2 表示拐点后的直线的斜率,简称为拐点直线斜率,p 表示拐点的 x 轴的值,也即拐点利用率,x - p 表示超额利用率,k*p + b 表示拐点时 y 的高度,简称拐点y 值。

JumpRateModelV2 合约继承自 BaseJumpRateModelV2 合约,大部分操作由后者来完成。

构造利率模型

拐点型利率模型的构造函数如下:

constructor(uint baseRatePerYear, uint multiplierPerYear, uint jumpMultiplierPerYear, uint kink_, address owner_)
    BaseJumpRateModelV2(baseRatePerYear,multiplierPerYear,jumpMultiplierPerYear,kink_,owner_) public {}

在构造函数中直接使用父合约的构造函数,从而代码体为空。

BaseJumpRateModelV2 合约的构造函数又调用 updateJumpRateModelInternal 函数。updateJumpRateModelInternal 函数代码如下:

function updateJumpRateModelInternal(uint baseRatePerYear, uint multiplierPerYear, uint jumpMultiplierPerYear, uint kink_) internal {
    // 拐点利用率之前,直线的截矩 b
    baseRatePerBlock = baseRatePerYear.div(blocksPerYear);

    // 拐点利用率之前,直线的斜率 k。注意这里的 k,与直接型利率模型中的有一些细微的不同。
    multiplierPerBlock = (multiplierPerYear.mul(1e18)).div(blocksPerYear.mul(kink_));

    // 拐点利用率之后,直线的斜率 k2
    jumpMultiplierPerBlock = jumpMultiplierPerYear.div(blocksPerYear);

    // 拐点时的资产利用率
    kink = kink_;

    emit NewInterestParams(baseRatePerBlock, multiplierPerBlock, jumpMultiplierPerBlock, kink);
}

利用率

无论是直线利率模型,还是拐点型利率模型,在计算利用率时都是一样,所以这里我们略过利用率的分析。

借款利率

借款利率的计算直接调用了父合约中的 getBorrowRateInternal 方法,这个方法的代码如下:

function getBorrowRateInternal(uint cash, uint borrows, uint reserves) internal view returns (uint) {
    // 计算资产利用率
    uint util = utilizationRate(cash, borrows, reserves);

    if (util <= kink) { // 资产利用率没有超过拐点
        return util.mul(multiplierPerBlock).div(1e18).add(baseRatePerBlock);
    } else { // 资产利用率已经超过拐点

        // 正常利率 = 拐点利用率 * 拐点前的斜率 + 截距
        uint normalRate = kink.mul(multiplierPerBlock).div(1e18).add(baseRatePerBlock);

        // 超额利用率
        uint excessUtil = util.sub(kink);

        // 超额利用率 * 拐点后斜率 + 正常利率
        return excessUtil.mul(jumpMultiplierPerBlock).div(1e18).add(normalRate);
    }
}

存款利率

存款利率代码如下:

function getSupplyRate(uint cash, uint borrows, uint reserves, uint reserveFactorMantissa) public view returns (uint) {
    uint oneMinusReserveFactor = uint(1e18).sub(reserveFactorMantissa);
    uint borrowRate = getBorrowRateInternal(cash, borrows, reserves);
    uint rateToPool = borrowRate.mul(oneMinusReserveFactor).div(1e18);
    return utilizationRate(cash, borrows, reserves).mul(rateToPool).div(1e18);
}

根据代码整理出来存储利率如下:

存款利率 = 资产利用率 * 借款利率 * (1 - 储备率)

总结

因为篇幅所限,无法在一片短短的文章中完整地分析整个 Compound 项目,今天这篇文章我们只分析了 Compound 的 cToken 和利率模型等合约的核心部分,同时个人水平有限,出现错误是在所难免的,欢迎对 Compound 项目感兴趣的小伙伴指出我的不足和建议,我会非常感谢。

  • 0
区块链智慧谷
区块链智慧谷

0 条评论