Compound v2 学习
Compound协议是一个开创性的去中心化借贷协议,允许用户以加密资产为抵押进行借贷并赚取利息。本文将基于Compound v2.0的源码,深入分析其架构设计和关键流程实现。
协议架构
Compound v2.0的架构可以分为以下几个层次:
- 用户接口层:提供了与用户交互的入口,包括Web UI、智能合约接口等。
- 代币层:包括cToken合约和underlying资产合约,管理用户的资产存取和cToken的铸造/销毁。
- 核心逻辑层:实现了协议的核心功能,如利率模型、清算逻辑等。
- 存储层:负责存储协议的各种状态变量,如账户余额、借款信息等。
- 预言机层:提供价格预言机,用于获取资产的实时价格,为清算提供依据。
关键模块
Compound v2.0的主要模块包括:
- CToken:cToken合约,代表存款人在underlying资产上的份额。
- Comptroller:协议的控制器合约,管理CToken的注册、利率模型、清算等。
- InterestRateModel:利率模型合约,根据资金池的供需关系动态调整借款利率。
- PriceOracle:价格预言机合约,提供underlying资产的实时价格。
下面我们将详细分析这些模块的关键流程。
CToken
CToken合约继承自ERC20标准,同时扩展了与Compound协议相关的功能。其关键函数包括:
- mint:当用户存入underlying资产时,触发mint函数,铸造等量的cToken给用户。
- redeem:当用户赎回cToken时,触发redeem函数,销毁cToken并将underlying资产返还给用户。
- borrow:当用户借出资产时,触发borrow函数,增加用户的借款余额。
- repayBorrow:当用户偿还借款时,触发repayBorrow函数,减少用户的借款余额。
CToken合约的铸造和赎回流程如下:
Comptroller
Comptroller合约是Compound协议的控制中心,负责协调各个模块的交互。其关键函数包括:
- enterMarkets:当用户首次使用某个CToken时,需要调用enterMarkets将其加入到用户的市场列表中。
- exitMarket:当用户不再使用某个CToken时,可以调用exitMarket将其从用户的市场列表中移除。
- mintAllowed:在用户铸造cToken之前,检查用户是否有足够的抵押品和未超过最大借款额度。
- borrowAllowed:在用户借出资产之前,检查用户是否有足够的抵押品和未超过最大借款额度。
- liquidateBorrowAllowed:在清算发生之前,检查清算是否合法(如借款人抵押不足,清算人有足够的资金等)。
Comptroller合约的关键流程如下:
InterestRateModel
InterestRateModel合约负责计算借款利率。Compound v2.0使用了一个双曲线模型,根据资金池的利用率动态调整利率。其关键函数包括:
- getBorrowRate:根据资金池的利用率计算当前的借款利率。
- getSupplyRate:根据资金池的利用率和储备金因子计算当前的存款利率。
利率模型的计算公式如下:
borrowRate = baseRate + multiplier * utilization / (1 - utilization)
supplyRate = borrowRate * (1 - reserveFactor)
其中,baseRate
和multiplier
是预设的参数,utilization
是资金池的利用率,reserveFactor
是储备金因子。
PriceOracle
PriceOracle合约负责提供underlying资产的实时价格,为清算提供依据。Compound v2.0支持多种价格预言机,如UniswapAnchoredView、ChainlinkPriceOracle等。其关键函数包括:
- getUnderlyingPrice:获取指定CToken对应的underlying资产价格。
- setUnderlyingPrice:设置underlying资产的价格(仅治理员可调用)。
Gas优化
- 状态变量的存储位置
在Solidity中,状态变量按照声明的顺序依次存储在合约的存储空间中。为了节省gas,我们应该尽量将frequently accessed的变量放在一起,这样可以减少存储槽的读写次数。
例如,在CToken合约中,accrualBlockNumber
和borrowIndex
这两个变量经常在一起读写,所以它们被放在了相邻的位置:
struct AccrualBlockNumber {
uint blockNumber;
}
struct BorrowIndex {
uint224 mantissa;
}
AccrualBlockNumber public accrualBlockNumber;
BorrowIndex public borrowIndex;
- 使用内存变量
对于中间计算结果,我们应该尽量使用内存变量而不是存储变量,因为内存的读写成本远低于存储。
例如,在Comptroller合约的distributeSupplierComp
函数中,supplierDelta
和supplierAccrued
都是内存变量:
function distributeSupplierComp(address cToken, address supplier) internal {
// ...
uint supplierDelta = sub_(compSupplyState[cToken].index, supplierIndex);
uint supplierAccrued = mul_(supplierTokens, supplierDelta);
// ...
}
- 避免不必要的计算
在合约中,我们应该尽量避免不必要的计算,特别是那些在循环中执行的计算。
例如,在Comptroller合约的getAccountLiquidity
函数中,sumCollateral
和sumBorrowPlusEffects
这两个变量只需要计算一次,所以它们被放在了循环之外:
function getAccountLiquidity(address account) public view returns (uint, uint, uint) {
// ...
uint sumCollateral = 0;
uint sumBorrowPlusEffects = 0;
for (uint i = 0; i < assets.length; i++) {
// ...
sumCollateral = add_(sumCollateral, mul_ScalarTruncate(collateralFactor, tokensToDenom(cTokenBalance, underlying)));
sumBorrowPlusEffects = add_(sumBorrowPlusEffects, opaqueTokensToDenom(borrowBalance, underlying));
}
// ...
}
合约安全
- 检查外部合约调用的返回值
在调用外部合约的函数时,我们必须检查返回值,以确保调用成功。
例如,在CToken合约的mintFresh
函数中,在调用doTransferIn
函数后,会检查返回值是否为0:
function mintFresh(address minter, uint mintAmount) internal {
// ...
uint err = doTransferIn(minter, mintAmount);
require(err == 0, "transfer failed");
// ...
}
- 使用SafeMath库
为了防止整数溢出和下溢,我们应该使用SafeMath库来进行算术运算。
- 使用require和assert
我们应该使用require来检查函数的输入参数和执行条件,使用assert来检查内部状态的一致性。
例如,在Comptroller合约的_supportMarket
函数中,使用require检查cToken合约是否已经被添加过:
function _supportMarket(CToken cToken) external returns (uint) {
// ...
require(!markets[address(cToken)].isListed, "market already added");
// ...
}
而在CToken合约的exchangeRateStored
函数中,使用assert检查计算得到的exchangeRate是否大于0:
function exchangeRateStored() public view returns (uint) {
// ...
uint exchangeRate = mul_(totalSupply, 1e18) / totalReserves;
assert(exchangeRate > 0);
// ...
}