Compound学习——借贷和清算

1 CToken、CErc20、CEther

这三个合约对应compound中的cToken,当我们给compound lend资产时,就会收到相应的cToken。例如lend ETH会得到cETH,lend DAI会得到cDAI。

关于某个erc20代币/eth的lend和borrow的信息,都会存储在相应的cToken合约之中。例如lend和borrow DAI的一切信息,都会记录在cDAI合约之中。我们可以简单看一下cToken的接口方法。

其中一部分是ERC20标准的函数,例如transfer、approve,可见我们lend之后得到的cToken本身具备ERC20代币的性质。

有一些是因为借贷而特有的,例如balanceOfUnderlying获取某个用户的cToken对应的底层资产数量,supplyRatePerBlock/borrowRatePerBlock获得当前利率。

还有一些是当前资金池的状态,getCash获得当前资金池的底层资产数量。

另外还有一些管理员函数,用于修改资金池核心控制参数,这些是通过DAO治理投票才可以修改的。

/*** User Interface ***/
function transfer(address dst, uint amount) virtual external returns (bool);
function transferFrom(address src, address dst, uint amount) virtual external returns (bool);
function approve(address spender, uint amount) virtual external returns (bool);
function allowance(address owner, address spender) virtual external view returns (uint);
function balanceOf(address owner) virtual external view returns (uint);

function balanceOfUnderlying(address owner) virtual external returns (uint);
function getAccountSnapshot(address account) virtual external view returns (uint, uint, uint, uint);
function borrowRatePerBlock() virtual external view returns (uint);
function supplyRatePerBlock() virtual external view returns (uint);
function totalBorrowsCurrent() virtual external returns (uint);
function borrowBalanceCurrent(address account) virtual external returns (uint);
function borrowBalanceStored(address account) virtual external view returns (uint);
function exchangeRateCurrent() virtual external returns (uint);
function exchangeRateStored() virtual external view returns (uint);
function getCash() virtual external view returns (uint);
function accrueInterest() virtual external returns (uint);
function seize(address liquidator, address borrower, uint seizeTokens) virtual external returns (uint);

/*** Admin Functions ***/
function _setPendingAdmin(address payable newPendingAdmin) virtual external returns (uint);
function _acceptAdmin() virtual external returns (uint);
function _setComptroller(ComptrollerInterface newComptroller) virtual external returns (uint);
function _setReserveFactor(uint newReserveFactorMantissa) virtual external returns (uint);
function _reduceReserves(uint reduceAmount) virtual external returns (uint);
function _setInterestRateModel(InterestRateModel newInterestRateModel) virtual external returns (uint);

由于ETH并不是ERC20代币,所以接口的封装和Erc20有所不同(对于erc20代币,amount通过函数参数带进去;而对于eth,amount需要通过msg.value带进去)。因此在CToken合约的基础上,出现了CErc20和CEther两个上层封装合约,这两个合约都继承了CToken。

下图是CErc20合约接口:

mint:用户lend给借贷池,为用户mint相应的cToken代币。

redeem/redeemUnderlying:用户取出自己借给借贷池的资产,销毁对应的cToken。

borrow:用户从借贷池borrow资产。

repayBorrow/repayBorrowBehalf:用户偿还borrow的资产。

liquidateBorrow:清算。

sweepToken:管理员函数,如果有错误被转入该合约的ERC20代币,可用此方法把代币转给管理员。

_addReserves:增加借贷池的reserve资产。

abstract contract CErc20Interface is CErc20Storage {
    /*** User Interface ***/
    function mint(uint mintAmount) virtual external returns (uint);
    function redeem(uint redeemTokens) virtual external returns (uint);
    function redeemUnderlying(uint redeemAmount) virtual external returns (uint);
    function borrow(uint borrowAmount) virtual external returns (uint);
    function repayBorrow(uint repayAmount) virtual external returns (uint);
    function repayBorrowBehalf(address borrower, uint repayAmount) virtual external returns (uint);
    function liquidateBorrow(address borrower, uint repayAmount, CTokenInterface cTokenCollateral) virtual external returns (uint);
    function sweepToken(EIP20NonStandardInterface token) virtual external;

    /*** Admin Functions ***/
    function _addReserves(uint addAmount) virtual external returns (uint);
}

对于CEth,接口功能基本一致,只是把eth的amount参数移除(需要通过msg.value)带进去:

contract CEther is CToken {
    function mint() external payable;
    function redeem(uint redeemTokens) external returns (uint);
    function redeemUnderlying(uint redeemAmount) external returns (uint);
    function borrow(uint borrowAmount) external returns (uint);
    function repayBorrow() external payable;
    function repayBorrowBehalf(address borrower) external payable;
    function liquidateBorrow(address borrower, CToken cTokenCollateral) external payable;
    function _addReserves() external payable returns (uint);
}

还有一点需要指出,ERC20的CToken实际上是通过代理合约CErc20Delegator调用的。

可能因为是早期实现的,它并不是我们现在流行的uups或者transparent可升级合约,不过基本原理还是一样,主要是通过delegatecall调用CErc20Delegate的代码。不过对于view函数,并没有这样做,而是通过staticcall调用了自身的代码。为什么要这样做呢?或许是觉得这样做更加安全,因为delegatecall不能保证不修改数据。

contract CErc20Delegate is CErc20, CDelegateInterface {
    ......
}

contract CErc20Delegator is CTokenInterface, CErc20Interface, CDelegatorInterface {
    address public implementation;
    ......

    function _setImplementation(address implementation_, bool allowResign, bytes memory becomeImplementationData)override public {
        require(msg.sender == admin, "CErc20Delegator::_setImplementation: Caller must be admin");

        if (allowResign) {
            delegateToImplementation(abi.encodeWithSignature("_resignImplementation()"));
        }

        address oldImplementation = implementation;
        implementation = implementation_;

        delegateToImplementation(abi.encodeWithSignature("_becomeImplementation(bytes)", becomeImplementationData));

        emit NewImplementation(oldImplementation, implementation);
    }

    function mint(uint mintAmount) override external returns (uint) {
        bytes memory data = delegateToImplementation(abi.encodeWithSignature("mint(uint256)", mintAmount));
        return abi.decode(data, (uint));
    }
    ......

    function getAccountSnapshot(address account) override external view returns (uint, uint, uint, uint) {
        bytes memory data = delegateToViewImplementation(abi.encodeWithSignature("getAccountSnapshot(address)", account));
        return abi.decode(data, (uint, uint, uint, uint));
    }
    ......

    function delegateTo(address callee, bytes memory data) internal returns (bytes memory) {
        (bool success, bytes memory returnData) = callee.delegatecall(data);
        assembly {
            if eq(success, 0) {
                revert(add(returnData, 0x20), returndatasize())
            }
        }
        return returnData;
    }

    function delegateToImplementation(bytes memory data) public returns (bytes memory) {
        return delegateTo(implementation, data);
    }

    function delegateToViewImplementation(bytes memory data) public view returns (bytes memory) {
        (bool success, bytes memory returnData) = address(this).staticcall(abi.encodeWithSignature("delegateToImplementation(bytes)", data));
        assembly {
            if eq(success, 0) {
                revert(add(returnData, 0x20), returndatasize())
            }
        }
        return abi.decode(returnData, (bytes));
    }

    fallback() external payable {
        require(msg.value == 0,"CErc20Delegator:fallback: cannot send value to fallback");

        // delegate all other functions to current implementation
        (bool success, ) = implementation.delegatecall(msg.data);

        assembly {
            let free_mem_ptr := mload(0x40)
            returndatacopy(free_mem_ptr, 0, returndatasize())

            switch success
            case 0 { revert(free_mem_ptr, returndatasize()) }
            default { return(free_mem_ptr, returndatasize()) }
        }
    }
}

2 Comptroller

上一节说的cToken合约,每一种不同资产是相互独立的,例如dai和usdc对应的是不同的ctoken合约。但对于某个具体用户来说,如果要统计ta的资产状况、计算ta还剩多少借款额度等,则需要把compound支持的所有资产都考虑进来。此时,cToken合约就不能胜任这个任务了,需要一个总的控制合约来,这就是Comptroller。

下面截取了一些Comptroller但重要接口函数。

enterMarkets:选取某些代币资产作为抵押物。

exitMarket:让某种代币资产不再作为抵押物。

borrowAllowed:计算借款额度是否足够。

repayBorrowAllowed:计算还款数额是否合法。

liquidateBorrowAllowed:计算清算数额是否合法。

seizeAllowed:清算时清算人需要指定获取的抵押物,计算是否合法。

liquidateCalculateSeizeTokens:计算清算后可以获得的token数目。

abstract contract ComptrollerInterface {
    /*** Assets You Are In ***/
    function enterMarkets(address[] calldata cTokens) virtual external returns (uint[] memory);
    function exitMarket(address cToken) virtual external returns (uint);

    /*** Policy Hooks ***/
    function borrowAllowed(address cToken, address borrower, uint borrowAmount) virtual external returns (uint);
    function repayBorrowAllowed(
        address cToken,
        address payer,
        address borrower,
        uint repayAmount) virtual external returns (uint);
    function liquidateBorrowAllowed(
        address cTokenBorrowed,
        address cTokenCollateral,
        address liquidator,
        address borrower,
        uint repayAmount) virtual external returns (uint);
    function seizeAllowed(
        address cTokenCollateral,
        address cTokenBorrowed,
        address liquidator,
        address borrower,
        uint seizeTokens) virtual external returns (uint);


    /*** Liquidity/Liquidation Calculations ***/
    function liquidateCalculateSeizeTokens(
        address cTokenBorrowed,
        address cTokenCollateral,
        uint repayAmount) virtual external view returns (uint, uint);
    ......
}

存贷款的逻辑无需解释,下面看一下清算模型。

用户可以选择将自己存入compound的某种资产作为抵押物(enterMarkets函数)。为了控制风险,抵押都是超额抵押,例如存入价值10000USD的ETH,能借出来的资产价值一定是小于10000USD的。控制该额度的参数叫collateralfactor,每种资产的factor都不一样,并且是可以经过DAO治理修改的。目前ETH的factor是82.5%,这意味着存入10000USD价值的ETH,最多只能借出8250USD的资产。

post image

我们在compound官网主页上可以看到borrow limit,这就是我们所有的存入资产乘以相应的factor之后相加的总量。这也是我们所能够借出来的资产价值上限。

post image

我们不可能通过超额借款让自己被清算,因为此时智能合约会执行失败。不过,如果市场波动币价下跌,是有可能让我们的实际借款总额超过可借款总额的,作为用户应该尽量避免这种情况发生。

一旦发生这种情况,我们的账户就进入可清算状态。此时任何人都可以替我们偿还一部分借款,同时拿走相应价值的抵押物。为了鼓励清算者及时清算维持协议的健康运行,清算者将会获得奖励。被清算人会承担一定比例的损失(目前是8%),损失的资产大部分会奖励给清算者,小部分(2.5%,如果抵押物是eth则没有这部分)会被协议没收。清算奖励参数可以通过DAO治理投票修改。

清算的时候会有一个最大比例closeFactor,例如被清算人借了10000DAI,如果closeFactor为50%,那么在一次交易中最多只能清算5000DAI。不过这样的清算可以一直继续下去,直到被清算人不再是超额借款为止。

和cToken类似,Comptroller也是一个代理合约,unitroller

有点特别的是,合约升级被分成了2步,先setPending,然后再由新的Implement发起accept。

contract Unitroller is UnitrollerAdminStorage, ComptrollerErrorReporter {
    ......
    fallback() payable external {
        // delegate all other functions to current implementation
        (bool success, ) = comptrollerImplementation.delegatecall(msg.data);

        assembly {
              let free_mem_ptr := mload(0x40)
              returndatacopy(free_mem_ptr, 0, returndatasize())

              switch success
              case 0 { revert(free_mem_ptr, returndatasize()) }
              default { return(free_mem_ptr, returndatasize()) }
        }
    }

    /*** Admin Functions ***/
    function _setPendingImplementation(address newPendingImplementation) public returns (uint) {
        if (msg.sender != admin) {
            return fail(Error.UNAUTHORIZED, FailureInfo.SET_PENDING_IMPLEMENTATION_OWNER_CHECK);
        }
        ......
    }

    function _acceptImplementation() public returns (uint) {
                // Check caller is pendingImplementation and pendingImplementation ≠ address(0)
                if (msg.sender != pendingComptrollerImplementation || pendingComptrollerImplementation == address(0)) {
                    return fail(Error.UNAUTHORIZED, FailureInfo.ACCEPT_PENDING_IMPLEMENTATION_ADDRESS_CHECK);
                }
        ......
    }
}

那么implement是怎样的呢?因为代码很多,这里只看storage部分,我们可以看到迄今为止,comtroller至今已到到了第7个版本,每次更新都会在之前的基础上增加一些内容。

contract ComptrollerV1Storage is UnitrollerAdminStorage {
    ......
}

contract ComptrollerV2Storage is ComptrollerV1Storage {
    ......
}

contract ComptrollerV3Storage is ComptrollerV2Storage {
    ...... 
}

contract ComptrollerV4Storage is ComptrollerV3Storage {
    ......
}

contract ComptrollerV5Storage is ComptrollerV4Storage {
    ......
}

contract ComptrollerV6Storage is ComptrollerV5Storage {
    ......
}

contract ComptrollerV7Storage is ComptrollerV6Storage {
    ......
}

contract Comptroller is ComptrollerV7Storage, ComptrollerInterface, ComptrollerErrorReporter, ExponentialNoError {
    ......
}

刚才提到的implement发起_acceptImplementation完成升级的函数如下:

function _become(Unitroller unitroller) public {
    require(msg.sender == unitroller.admin(), "only unitroller admin can change brains");
    require(unitroller._acceptImplementation() == 0, "change not authorized");
}

3 InterestRateModel

对于借贷协议,利率模型固然是非常重要的。

很显然,当资金池的资金利用率低的时候(借钱的人少),应该鼓励借钱、不鼓励存钱,因此存贷利率都应该更低;反之存贷利率都应该更高。按照白皮书的描述,compound的利率是随资金利用率线性递增的。

借款利率 = 2.5% + 资金利用率 * 20%

存款利率 = 借款利率 * 资金利用率

post image
function getBorrowRate(uint cash, uint borrows, uint reserves) override public view returns (uint) {
        uint ur = utilizationRate(cash, borrows, reserves);
        return (ur * multiplierPerBlock / BASE) + baseRatePerBlock;
}

但实际上,compound目前的支持的资产貌似都没有使用白皮书中的利率模型,而是使用了JumpRateModel,在资金利用率超过某个阈值(往往是80%)时,利率增长会变得非常陡峭,这样做是为了最大程度地避免资金池流动性枯竭。

利率模型是可以经由DAO治理投票通过_setInterestRateModel进行修改的。

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;
    }
}

不同的资产,使用的具体参数不尽相同,可以在这里查看。

https://observablehq.com/@jflatow/compound-interest-rates

目前DAI的利率和资金利用率的关系图如下:

post image

在确定利率模型之后,还有一点重要的事情是,利率的计算,这里核心的问题是计算出累积利率。

从理论上来说,AccrueInterest(t1) = AccrueInterest(t0) * (1 + Interest)^(t1 - t0)

如果t1-t0足够小,那么可以把乘方简化为乘法:

AccrueInterest(t1) = AccrueInterest(t0) * (1 + Interest) * (t1 - t0)

在compound中,任意一次借贷操作都会触发一次AccrueInterest刷新,如果这个资金池的交易足够活跃,简化之后的误差是可以忽略的。如果某个资金池很久都没有人使用,可能需要自动脚本每隔一段时间触发一次。

function accrueInterest() virtual override public returns (uint) {
    /* Remember the initial block number */
    uint currentBlockNumber = getBlockNumber();
    uint accrualBlockNumberPrior = accrualBlockNumber;

    /* Short-circuit accumulating 0 interest */
    if (accrualBlockNumberPrior == currentBlockNumber) {
        return NO_ERROR;
    }

    ......

    /* Calculate the current borrow interest rate */
    uint borrowRateMantissa = interestRateModel.getBorrowRate(cashPrior, borrowsPrior, reservesPrior);
    require(borrowRateMantissa <= borrowRateMaxMantissa, "borrow rate is absurdly high");

    /* Calculate the number of blocks elapsed since the last accrual */
    uint blockDelta = currentBlockNumber - accrualBlockNumberPrior;

    Exp memory simpleInterestFactor = mul_(Exp({mantissa: borrowRateMantissa}), blockDelta);

    ......
    return NO_ERROR;
}

4 一个实例

上面简单介绍了compound协议中最核心的几个内容,下面我们将从最重要的几个用户接口切入,具体看协议中各个功能是如何实现的。

例子如下:

contract TestScript is Script, Test {
    address payable user1 = payable(0x755557E102286F31F83BdE39c007cEE46D12D321);
    address payable user2 = payable(0xAdfaD0B8ccbAD46a009fAa4480E7986378a679bb);

    CEther cETH = CEther(payable(0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5));
    CErc20 cDAI = CErc20(0x5d3a536E4D6DbD6114cc1Ead35777bAB948E3643);

    IERC20 dai = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);
    PriceOracle oracle = PriceOracle(0x65c816077C29b557BEE980ae3cC2dCE80204A0C5);

    Comptroller comptroller = Comptroller(0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B);

    function setUp() public view {}

    function run() public {
        vm.startPrank(user1);
        vm.deal(user1, 20 ether);
        deal(address(dai), user2, 10000 ether);

        console.log("ETH price: ", oracle.getUnderlyingPrice(cETH));
        console.log("DAI price: ", oracle.getUnderlyingPrice(cDAI));

        cETH.mint{value: 15 ether}();
        cETH.redeemUnderlying(5 ether);

        (, uint collateralFactorMantissa, ) = comptroller.markets(address(cETH));
        console2.log("eth collateral factor: ", collateralFactorMantissa);

        address[] memory collateralToken = new addressUnsupported embed;
        collateralToken[0] = address(cETH);
        comptroller.enterMarkets(collateralToken);
        cDAI.borrow(10500 ether);
        dai.approve(address(cDAI), 500 ether);
        cDAI.repayBorrow(500 ether);

        (, uint liquidity, uint shortfall) = comptroller.getAccountLiquidity(user1);
        console2.log("before adjust: liquidity: ",liquidity, ";shortfall: ", shortfall);

        vm.stopPrank();
        vm.prank(0x6d903f6003cca6255D85CcA4D3B5E5146dC33925);
        comptroller._setCollateralFactor(cETH, 100000000 gwei);
        vm.startPrank(user2);

        (, liquidity, shortfall) = comptroller.getAccountLiquidity(user1);
        console2.log("after adjust: liquidity: ",liquidity, ";shortfall: ", shortfall);

        dai.approve(address(cDAI), 5000 ether);
        cDAI.liquidateBorrow(user1, 5000 ether, cETH);

        (, liquidity, shortfall) = comptroller.getAccountLiquidity(user1);
        console2.log("after liquidation: liquidity: ",liquidity, ";shortfall: ", shortfall);

        console2.log("user1 eth balance: ",cETH.balanceOfUnderlying(user1));
        console2.log("user2 eth balance: ",cETH.balanceOfUnderlying(user2));

        vm.stopPrank();
}

本次试验中,ETH价格1386.916674USD,DAI价格1.000114USD,ETH的collateral factor为82.5%.

  1. user1存入15ETH,再取出5ETH,还剩下10ETH。

  2. user1把eth设定为抵押物。

  3. user1借出10500DAI,在偿还500DAI,还剩下10000DAI的债务。

  4. 此时查看getAccountLiquidity的借款额度:13869.16674*0.825-10001.14=1440.92256

  5. 利用foundry的prank功能模拟管理员账户,修改ETH的collateral factor为10%. 此时再查看借款额度:13869.16674*0.1-10001.14=-8614.22333。可见该账户进入可清算状态。

  6. user2账户对user1发起清算,偿还5000DAI。偿还之后,user2获得user1的ETH抵押物:(5000*1.000114/1386.916674)*1.08 = 3.89397265261

post image

4.1 存款

post image

存款的逻辑比较简单,直接调用相应cToken的mint函数。

首先需要调用accrueInterest()计算累积利率,这一点上面已经提到过,任何借贷行为都会引发累积利率的更新,本节后面的几个操作也都会触发这一函数。

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);
}

接下来就是mint。

首先需要调用comptroller的mintAllowed函数判断,当前compound是否支持该资产。

接下来要根据存入的数目计算应该给用户发放多少cToken,这个比例显然和累积利率相关。cToken price/token price的值是随时间增长而不断变大的,所以用户持有的cToken值一直不变,但是因为比例不断变大,所以对应的token会逐渐变多。

然后, uint actualMintAmount = doTransferIn(minter, mintAmount);把用户存入的token归入资金池。这里还要计算一个actualMintAmount,是考虑到某些token的转账要收手续费,所以最终入账的amount不一定和参数中的amount一样。

最后accountTokens[minter] = accountTokens[minter] + mintTokens;给用户mint相应的cToken。

function mintFresh(address minter, uint mintAmount) internal {
    uint allowed = comptroller.mintAllowed(address(this), minter, mintAmount);
    if (allowed != 0) {
        revert MintComptrollerRejection(allowed);
    }

    if (accrualBlockNumber != getBlockNumber()) {
        revert MintFreshnessCheck();
    }

    Exp memory exchangeRate = Exp({mantissa: exchangeRateStoredInternal()});

    uint actualMintAmount = doTransferIn(minter, mintAmount);

    uint mintTokens = div_(actualMintAmount, exchangeRate);

    totalSupply = totalSupply + mintTokens;
    accountTokens[minter] = accountTokens[minter] + mintTokens;

    emit Mint(minter, actualMintAmount, mintTokens);
    emit Transfer(address(this), minter, mintTokens);
}

4.2 取款

post image

总体上是存款的逆过程。值得注意的是,compound支持2个redeem函数,一个是指定cToken的amount,另一个是指定底层资产的amount,分别对应redeemFresh中第一个if判断的2个分支。

function redeemUnderlyingInternal(uint redeemAmount) internal nonReentrant {
    accrueInterest();
    redeemFresh(payable(msg.sender), 0, redeemAmount);
}

function redeemFresh(address payable redeemer, uint redeemTokensIn, uint redeemAmountIn) internal {
    require(redeemTokensIn == 0 || redeemAmountIn == 0, "one of redeemTokensIn or redeemAmountIn must be zero");

    Exp memory exchangeRate = Exp({mantissa: exchangeRateStoredInternal() });

    uint redeemTokens;
    uint redeemAmount;
    if (redeemTokensIn > 0) {
        redeemTokens = redeemTokensIn;
        redeemAmount = mul_ScalarTruncate(exchangeRate, redeemTokensIn);
    } else {
        redeemTokens = div_(redeemAmountIn, exchangeRate);
        redeemAmount = redeemAmountIn;
    }

    uint allowed = comptroller.redeemAllowed(address(this), redeemer, redeemTokens);
    if (allowed != 0) {
        revert RedeemComptrollerRejection(allowed);
    }

    if (accrualBlockNumber != getBlockNumber()) {
        revert RedeemFreshnessCheck();
    }

    if (getCashPrior() < redeemAmount) {
        revert RedeemTransferOutNotPossible();
    }
    totalSupply = totalSupply - redeemTokens;
    accountTokens[redeemer] = accountTokens[redeemer] - redeemTokens;

    doTransferOut(redeemer, redeemAmount);

    emit Transfer(redeemer, address(this), redeemTokens);
    emit Redeem(redeemer, redeemAmount, redeemTokens);

    comptroller.redeemVerify(address(this), redeemer, redeemAmount, redeemTokens);
}

不过取款可能导致抵押物不够,所以需要判断一下,这就是comptroller.redeemAllowed所做的事情。

当前的例子中,因为还没有把ETH作为抵押物,所以在redeemAllowedInternal的if (!markets[cToken].accountMembership[redeemer])就是直接返回。

如果用户已经设置了抵押物,那么会继续往下走。核心逻辑在getHypotheticalAccountLiquidityInternal,函数较长,只展示关键代码,它会遍历用户名下涉及的所有资产,以比较用户的总借款额和最大允许借款额。

function redeemAllowedInternal(address cToken, address redeemer, uint redeemTokens) internal view returns (uint) {
    if (!markets[cToken].isListed) {
        return uint(Error.MARKET_NOT_LISTED);
    }

    if (!markets[cToken].accountMembership[redeemer]) {
        return uint(Error.NO_ERROR);
    }

    (Error err, , uint shortfall) = getHypotheticalAccountLiquidityInternal(redeemer, CToken(cToken), redeemTokens, 0);
    if (err != Error.NO_ERROR) {
        return uint(err);
    }
    if (shortfall > 0) {
        return uint(Error.INSUFFICIENT_LIQUIDITY);
    }

    return uint(Error.NO_ERROR);
}

function getHypotheticalAccountLiquidityInternal(
    ......
    // For each asset the account is in
    CToken[] memory assets = accountAssets[account];
    for (uint i = 0; i < assets.length; i++) {
        ...
        vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);
        vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom, vars.cTokenBalance, vars.sumCollateral);
        vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, vars.borrowBalance, vars.sumBorrowPlusEffects);
        // Calculate effects of interacting with cTokenModify
        if (asset == cTokenModify) {
            vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.tokensToDenom, redeemTokens, vars.sumBorrowPlusEffects);
            vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, borrowAmount, vars.sumBorrowPlusEffects);
        }
    }

    // These are safe, as the underflow condition is checked first
    if (vars.sumCollateral > vars.sumBorrowPlusEffects) {
        return (Error.NO_ERROR, vars.sumCollateral - vars.sumBorrowPlusEffects, 0);
    } else {
        return (Error.NO_ERROR, 0, vars.sumBorrowPlusEffects - vars.sumCollateral);
    }
}

4.3 设置抵押物

post image

逻辑非常简单,只要在合约里做个记录就好了。无论当前用户是否出于可清算状态,增加抵押物都是被允许的,所以不需要做相关的判断。

function addToMarketInternal(CToken cToken, address borrower) internal returns (Error) {
    Market storage marketToJoin = markets[address(cToken)];

    if (!marketToJoin.isListed) {
        return Error.MARKET_NOT_LISTED;
    }

    if (marketToJoin.accountMembership[borrower] == true) {
        return Error.NO_ERROR;
    }

    marketToJoin.accountMembership[borrower] = true;
    accountAssets[borrower].push(cToken);

    emit MarketEntered(cToken, borrower);

    return Error.NO_ERROR;
}

不过,如果是想取消某个抵押物,则需要判断是否会导致用户进入可清算状态。

我们可以看到下面这行代码:

uint allowed = redeemAllowedInternal(cTokenAddress, msg.sender, tokensHeld);

假设当前用户把某个资产全部取出,不会导致清算,此时,才允许把这项资产取消抵押。

function exitMarket(address cTokenAddress) override external returns (uint) {
    ......

    /* Fail if the sender is not permitted to redeem all of their tokens */
    uint allowed = redeemAllowedInternal(cTokenAddress, msg.sender, tokensHeld);
    if (allowed != 0) {
        return failOpaque(Error.REJECTION, FailureInfo.EXIT_MARKET_REJECTION, allowed);
    }

    ......
    delete marketToExit.accountMembership[msg.sender];
    CToken[] memory userAssetList = accountAssets[msg.sender];
    uint len = userAssetList.length;
    uint assetIndex = len;
    for (uint i = 0; i < len; i++) {
        if (userAssetList[i] == cToken) {
            assetIndex = i;
            break;
        }
    }
    assert(assetIndex < len);

    CToken[] storage storedList = accountAssets[msg.sender];
    storedList[assetIndex] = storedList[storedList.length - 1];
    storedList.pop();

    emit MarketExited(cToken, msg.sender);

    return uint(Error.NO_ERROR);
}

4.4 借款

post image

看总体流程和mint很像,不同之处是需要判断是否允许借款。

comptroller.borrowAllowed判断抵押物是否足够。

if (getCashPrior() < borrowAmount)判断资金池是否有足够的token。

function borrowFresh(address payable borrower, uint borrowAmount) internal {
    /* Fail if borrow not allowed */
    uint allowed = comptroller.borrowAllowed(address(this), borrower, borrowAmount);
    if (allowed != 0) {
        revert BorrowComptrollerRejection(allowed);
    }
    ......
    /* Fail gracefully if protocol has insufficient underlying cash */
    if (getCashPrior() < borrowAmount) {
        revert BorrowCashNotAvailable();
    }

    uint accountBorrowsPrev = borrowBalanceStoredInternal(borrower);
    uint accountBorrowsNew = accountBorrowsPrev + borrowAmount;
    uint totalBorrowsNew = totalBorrows + borrowAmount;

    accountBorrows[borrower].principal = accountBorrowsNew;
    accountBorrows[borrower].interestIndex = borrowIndex;
    totalBorrows = totalBorrowsNew;

    doTransferOut(borrower, borrowAmount);

    emit Borrow(borrower, borrowAmount, accountBorrowsNew, totalBorrowsNew);
}

我们主要来看comptroller.borrowAllowed。

首先判断该cToken的借款是否被暂停以及是否被支持。

然后判断是否要borrow的amount是否超过了borrowCap,这是一个可以通过DAO治理修改的数字。目前大部分资产应该没有设置此限制,即borrowCap=0.

接下来通过getHypotheticalAccountLiquidityInternal判断借款后是否会导致用户超额借款,这个函数已经在取款那一小节里看过。

function borrowAllowed(address cToken, address borrower, uint borrowAmount) override external returns (uint) {
    require(!borrowGuardianPaused[cToken], "borrow is paused");

    if (!markets[cToken].isListed) {
        return uint(Error.MARKET_NOT_LISTED);
    }

    ......

    uint borrowCap = borrowCaps[cToken];
    // Borrow cap of 0 corresponds to unlimited borrowing
    if (borrowCap != 0) {
        uint totalBorrows = CToken(cToken).totalBorrows();
        uint nextTotalBorrows = add_(totalBorrows, borrowAmount);
        require(nextTotalBorrows < borrowCap, "market borrow cap reached");
    }

    (Error err, , uint shortfall) = getHypotheticalAccountLiquidityInternal(borrower, CToken(cToken), 0, borrowAmount);
    if (err != Error.NO_ERROR) {
        return uint(err);
    }
    if (shortfall > 0) {
        return uint(Error.INSUFFICIENT_LIQUIDITY);
    }
    ......

    return uint(Error.NO_ERROR);
}

4.5 还款

post image

这个函数的流程比较简单。

一开始是判断repay是否合法:comptroller.repayBorrowAllowed。显然repay是不会导致超额借款的,所以这里实际上仅仅是判断了当前compound协议是否list了该cToken。

可以注意到一点,如果repayAmount写-1(最大uint数字),那么会被认为是偿还所有该token的债务。

接下来还有一个actualRepayAmount的问题,因为某些代币转账会收取手续费导致actualRepayAmount小于repayAmount。

function repayBorrowFresh(address payer, address borrower, uint repayAmount) internal returns (uint) {
    uint allowed = comptroller.repayBorrowAllowed(address(this), payer, borrower, repayAmount);
    if (allowed != 0) {
        revert RepayBorrowComptrollerRejection(allowed);
    }

    if (accrualBlockNumber != getBlockNumber()) {
        revert RepayBorrowFreshnessCheck();
    }

    uint accountBorrowsPrev = borrowBalanceStoredInternal(borrower);

    /* If repayAmount == -1, repayAmount = accountBorrows */
    uint repayAmountFinal = repayAmount == type(uint).max ? accountBorrowsPrev : repayAmount;

    uint actualRepayAmount = doTransferIn(payer, repayAmountFinal);

    uint accountBorrowsNew = accountBorrowsPrev - actualRepayAmount;
    uint totalBorrowsNew = totalBorrows - actualRepayAmount;

    accountBorrows[borrower].principal = accountBorrowsNew;
    accountBorrows[borrower].interestIndex = borrowIndex;
    totalBorrows = totalBorrowsNew;

    emit RepayBorrow(payer, borrower, actualRepayAmount, accountBorrowsNew, totalBorrowsNew);
    return actualRepayAmount;
}

值得注意的是,这里并没有判断repayAmount小于borrowAmount,如果repay超额,会在下面这句overflow,导致交易失败:

uint accountBorrowsNew = accountBorrowsPrev - actualRepayAmount;

4.6 清算

post image
post image

这是最复杂的一个流程。我们来看一下liquidate的接口函数。

这是cToken合约的函数,cToken合约本身指定了我们想要清算(为清算人偿还)何种资产,borrower是被清算人,repayAmount是清算amount。上面有说到,单次交易最大只能清算50%(撰写本文时Comptroller合约的设定)的借款额。

此外还有一个cTokenCollateral函数,用户有可能有多种资产作为抵押物,因此我们需要指定repay之后,我们可以得到哪种抵押物。我们得到抵押物的价值应该是我们repay的价值的1.08倍,不过有一种情况是用户根本没有那么多的cTokenCollateral抵押物,此时liqudate会执行失败。所以在设置repayAmount和cTokenCollateral的时候需要自己预先计算一下。

function liquidateBorrow(address borrower, uint repayAmount, CTokenInterface cTokenCollateral) override external returns (uint)

清算函数的主体如下,大致可以划分为4个部分。

function liquidateBorrowFresh(address liquidator, address borrower, uint repayAmount, CTokenInterface cTokenCollateral) internal {
    // 1.判断是否可清算
    /* Fail if liquidate not allowed */
    uint allowed = comptroller.liquidateBorrowAllowed(address(this), address(cTokenCollateral), liquidator, borrower, repayAmount);
    if (allowed != 0) {
        revert LiquidateComptrollerRejection(allowed);
    }


    ......

    // 2.清算(偿还借款),返回实际清算额
    uint actualRepayAmount = repayBorrowFresh(liquidator, borrower, repayAmount);


    // 3.计算可获取的抵押物数量
    (uint amountSeizeError, uint seizeTokens) = comptroller.liquidateCalculateSeizeTokens(address(this), address(cTokenCollateral), actualRepayAmount);
    require(amountSeizeError == NO_ERROR, "LIQUIDATE_COMPTROLLER_CALCULATE_AMOUNT_SEIZE_FAILED");
    require(cTokenCollateral.balanceOf(borrower) >= seizeTokens, "LIQUIDATE_SEIZE_TOO_MUCH");

    // 4.分配抵押物
    // If this is also the collateral, run seizeInternal to avoid re-entrancy, otherwise make an external call
    if (address(cTokenCollateral) == address(this)) {
        seizeInternal(address(this), liquidator, borrower, seizeTokens);
    } else {
        require(cTokenCollateral.seize(liquidator, borrower, seizeTokens) == NO_ERROR, "token seizure failed");
    }

    /* We emit a LiquidateBorrow event */
    emit LiquidateBorrow(liquidator, borrower, actualRepayAmount, address(cTokenCollateral), seizeTokens);
}
  • 判断是否可清算

我们首先关注到的是isDeprecated(CToken(cTokenBorrowed)), 这个函数用于判断cToken池子是否废弃。如果已经被废弃,那么只要repayAmount不超过borrowBalance,就直接判定可以清算并返回。此时,无需判断用户抵押资产是否足够。

如果cToken池没有废弃,则需要使用getAccountLiquidityInternal函数计算,用户是否有超额借款,只有超额借款的用户才可以被清算。

接下来,还需要判断是否超过closeFactor,这一点刚才已经解释过。如果这项判断也通过,则可返回成功。

我们注意到,在这里并没有判断borrowBalance >= repayAmount。如果borrowBalance < repayAmount,那么后面更新债务数额的时候会overflow导致交易失败,所以repayAmount时不可能超过borrowBalance的。不过,repayAmout是有可能超过用户的超额借款量的,这意味着用户又可能被多清算一些资产。这里可以看出closeFactor对用户具有一定的保护作用。

function liquidateBorrowAllowed(
    address cTokenBorrowed,
    address cTokenCollateral,
    address liquidator,
    address borrower,
    uint repayAmount) override external returns (uint) {
    ......
    uint borrowBalance = CToken(cTokenBorrowed).borrowBalanceStored(borrower);

    /* allow accounts to be liquidated if the market is deprecated */
    if (isDeprecated(CToken(cTokenBorrowed))) {
        require(borrowBalance >= repayAmount, "Can not repay more than the total borrow");
    } else {
        /* The borrower must have shortfall in order to be liquidatable */
        (Error err, , uint shortfall) = getAccountLiquidityInternal(borrower);
        if (err != Error.NO_ERROR) {
            return uint(err);
        }

        if (shortfall == 0) {
            return uint(Error.INSUFFICIENT_SHORTFALL);
        }

        /* The liquidator may not repay more than what is allowed by the closeFactor */
        uint maxClose = mul_ScalarTruncate(Exp({mantissa: closeFactorMantissa}), borrowBalance);
        if (repayAmount > maxClose) {
            return uint(Error.TOO_MUCH_REPAY);
        }
    }
    return uint(Error.NO_ERROR);
}
  • 清算(偿还借款),返回实际清算额

其实,这就是我们上面已经讲过的还款流程,不再重复叙述。因为可能的transfer手续费,实际清算额可能不等于之前传入的清算额。

  • 计算可获取的抵押物数量

从预言机中获取清算token和抵押物token的价格,然后计算可以获取多少抵押物。重点关注liquidationIncentiveMantissa,这是清算人的清算奖励,也是对被清算人的惩罚。

function liquidateCalculateSeizeTokens(address cTokenBorrowed, address cTokenCollateral, uint actualRepayAmount) override external view returns (uint, uint) {
    /* Read oracle prices for borrowed and collateral markets */
    uint priceBorrowedMantissa = oracle.getUnderlyingPrice(CToken(cTokenBorrowed));
    uint priceCollateralMantissa = oracle.getUnderlyingPrice(CToken(cTokenCollateral));
    if (priceBorrowedMantissa == 0 || priceCollateralMantissa == 0) {
        return (uint(Error.PRICE_ERROR), 0);
    }

    uint exchangeRateMantissa = CToken(cTokenCollateral).exchangeRateStored(); // Note: reverts on error
    uint seizeTokens;
    Exp memory numerator;
    Exp memory denominator;
    Exp memory ratio;

    numerator = mul_(Exp({mantissa: liquidationIncentiveMantissa}), Exp({mantissa: priceBorrowedMantissa}));
    denominator = mul_(Exp({mantissa: priceCollateralMantissa}), Exp({mantissa: exchangeRateMantissa}));
    ratio = div_(numerator, denominator);

    seizeTokens = mul_ScalarTruncate(ratio, actualRepayAmount);

    return (uint(Error.NO_ERROR), seizeTokens);
}
  • 分配抵押物

这一步,抵押物是ETH和是ERC20代币的情况略有不同,我们先看ERC20代币。

重点关注protocolSeizeShareMantissa和protocolSeizeAmount,我们可以看到抵押物并没有全部分配给清算者,而是有一部分被资金池没收了。这个比例目前是写死在代码里的,2.8%。

function seizeInternal(address seizerToken, address liquidator, address borrower, uint seizeTokens) internal {
    ......
    uint protocolSeizeTokens = mul_(seizeTokens, Exp({mantissa: protocolSeizeShareMantissa}));
    uint liquidatorSeizeTokens = seizeTokens - protocolSeizeTokens;
    Exp memory exchangeRate = Exp({mantissa: exchangeRateStoredInternal()});
    uint protocolSeizeAmount = mul_ScalarTruncate(exchangeRate, protocolSeizeTokens);
    uint totalReservesNew = totalReserves + protocolSeizeAmount;

    totalReserves = totalReservesNew;
    totalSupply = totalSupply - protocolSeizeTokens;
    accountTokens[borrower] = accountTokens[borrower] - seizeTokens;
    accountTokens[liquidator] = accountTokens[liquidator] + liquidatorSeizeTokens;

    emit Transfer(borrower, liquidator, liquidatorSeizeTokens);
    emit Transfer(borrower, address(this), protocolSeizeTokens);
    emit ReservesAdded(address(this), protocolSeizeAmount, totalReservesNew);
}

而cETH的seize函数并没有收取protocol费用,本文的例子中抵押物正是ETH,大家可以从合约执行过程图中看出来。下面的链接可以看到cETH合约代码:

https://etherscan.io/address/0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5#code

但是目前compound的github库中已经没有对cETH做这样的处理了。不过链上的cETH合约并非可升级代理合约(其他ERC20代币cToken都是可升级代理),因此并不容易修改,如果想修改只能将原本的cETH池子废弃重新部署一个新的。不知道是因为早期并没有考虑到升级因素还是项目方有意这般设计。

function seize(address liquidator, address borrower, uint seizeTokens) external nonReentrant returns (uint) {
    ......

    (mathErr, borrowerTokensNew) = subUInt(accountTokens[borrower], seizeTokens);
    if (mathErr != MathError.NO_ERROR) {
        return failOpaque(Error.MATH_ERROR, FailureInfo.LIQUIDATE_SEIZE_BALANCE_DECREMENT_FAILED, uint(mathErr));
    }

    (mathErr, liquidatorTokensNew) = addUInt(accountTokens[liquidator], seizeTokens);
    if (mathErr != MathError.NO_ERROR) {
        return failOpaque(Error.MATH_ERROR, FailureInfo.LIQUIDATE_SEIZE_BALANCE_INCREMENT_FAILED, uint(mathErr));
    }

    /* We write the previously calculated values into storage */
    accountTokens[borrower] = borrowerTokensNew;
    accountTokens[liquidator] = liquidatorTokensNew;

    emit Transfer(borrower, liquidator, seizeTokens);
    comptroller.seizeVerify(address(this), msg.sender, liquidator, borrower, seizeTokens);

    return uint(Error.NO_ERROR);
}