# Compound学习——借贷和清算 **Published by:** [rbtree](https://paragraph.com/@rbtree/) **Published on:** 2022-09-27 **URL:** https://paragraph.com/@rbtree/compound ## Content 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; } } 不同的资产,使用的具体参数不尽相同,可以在这里查看。 https://observablehq.com/@jflatow/compound-interest-rates 目前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 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%.user1存入15ETH,再取出5ETH,还剩下10ETH。user1把eth设定为抵押物。user1借出10500DAI,在偿还500DAI,还剩下10000DAI的债务。此时查看getAccountLiquidity的借款额度:13869.16674*0.825-10001.14=1440.92256利用foundry的prank功能模拟管理员账户,修改ETH的collateral factor为10%. 此时再查看借款额度:13869.16674*0.1-10001.14=-8614.22333。可见该账户进入可清算状态。user2账户对user1发起清算,偿还5000DAI。偿还之后,user2获得user1的ETH抵押物:(5000*1.000114/1386.916674)*1.08 = 3.893972652614.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合约代码: 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); } ## Publication Information - [rbtree](https://paragraph.com/@rbtree/): Publication homepage - [All Posts](https://paragraph.com/@rbtree/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@rbtree): Subscribe to updates