这三个合约对应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()) }
}
}
}
上一节说的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");
}
对于借贷协议,利率模型固然是非常重要的。
很显然,当资金池的资金利用率低的时候(借钱的人少),应该鼓励借钱、不鼓励存钱,因此存贷利率都应该更低;反之存贷利率都应该更高。按照白皮书的描述,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;
}
上面简单介绍了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%.
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.89397265261
存款的逻辑比较简单,直接调用相应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);
}
总体上是存款的逆过程。值得注意的是,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);
}
}
逻辑非常简单,只要在合约里做个记录就好了。无论当前用户是否出于可清算状态,增加抵押物都是被允许的,所以不需要做相关的判断。
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);
}
看总体流程和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);
}
这个函数的流程比较简单。
一开始是判断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;
这是最复杂的一个流程。我们来看一下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);
}