Compound学习——借贷和清算
September 27th, 2022

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的资产。

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

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

一旦发生这种情况,我们的账户就进入可清算状态。此时任何人都可以替我们偿还一部分借款,同时拿走相应价值的抵押物。为了鼓励清算者及时清算维持协议的健康运行,清算者将会获得奖励。被清算人会承担一定比例的损失(目前是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%

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

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

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

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

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

从理论上来说,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 address[](1);
        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

4.1 存款

存款的逻辑比较简单,直接调用相应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 取款

总体上是存款的逆过程。值得注意的是,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 设置抵押物

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

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 借款

看总体流程和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 还款

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

一开始是判断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 清算

这是最复杂的一个流程。我们来看一下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合约代码:

但是目前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);
}
Subscribe to rbtree
Receive the latest updates directly to your inbox.
Nft graphic
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from rbtree

Skeleton

Skeleton

Skeleton