本文环境
操作系统:maxOS 10.15.6
科学上网
公链网络:BSC Testnet
测试工具:Remix IDE、MetaMask
合约源码:https://github.com/compound-finance/compound-protocol.git
在 remix 进行编译部署时,勾选启用优化。
Compound中含有的合约代码量很大,文件数量30+,一开始笔者也忍不住打了退堂鼓。然而学习就是一个从难到易的过程,只有花时间去努力学习,才能慢慢理解它的结构和细节,最终会赞美Compound团队提供的优秀代码,优秀方案!
下图是网上找的Compound合约结构图,以飨读者。在部署前,先梳理清楚各个合约之间的关系,并将其分组,梳理出各个模块,及初始化参数。
一、COMP 模块
激励资产合约,可以使用标准 ERC20 合约。
因为 Comptroller 中会使用到 COMP 的地址,因此我们最先部署 COMP 合约,得到合约地址: 0x1fe7FF222D59B6648D640090046661313A1CF0a2
。
部署完成后,到合约 ComptrollerG7.sol (或者计划使用的 Comptroller 合约)进行配置,更改为自己的 COMP 合约地址。
也可以直接使用Comp.sol
部署 compound自带的COMP token合约,修改一下name,symbol,totalSupply等。
/**
* @notice Return the address of the COMP token
* @return The address of COMP
*/
function getCompAddress() public view returns (address) {
return 0x1fe7FF222D59B6648D640090046661313A1CF0a2;
}
二、comptroller 模块
在compound设计中,unitroller 是代理合约,comptroller 是逻辑实现合约,通过 delegatecall 来实现远程合约调用。
2.1 部署 Unitroller.sol
使用 account1 账号进行部署,成功:
contract address: 0x268e3eF4380DA46e55B77a6263810910722a875E
2.2 部署 ComptrollerG7.sol
使用 account1 账号进行部署;成功:
contract address: 0x67006E2110119Abfd40b2A743A85b4d3bF8967b9
三、priceOracle 模块
3.1 部署 SimplePriceOracle.sol
使用 account1 账号进行部署
contract address: 0x5991199a9aB1801A229a2E49a042471eDE997a21
四、绑定与设置
4.1 代理绑定
第一步: 在 Unitroller.sol 合约调用 _setPendingImplementation;
参数 address newPendingImplementation,这里设置为 ComptrollerG7.sol 地址第二步: 在 ComptrollerG7.sol 合约调用 _become,
参数 Unitroller unitroller,这里设置为 Unitroller.sol 地址
代理绑定,第一步转移所有权,第二步新的 Comptroller 接受所有权,这样就可以防止意外地升级到无效的合约;
备注:设置完成后对外提供 Comptroller 合约地址时, 提供的是 Unitroller 合约地址。
以下步骤,请 unitrollerProxy = ComptrollerG7(address(unitroller));
at Address unitrollerAddr得到unitrollerProxy合约,名字还是ComptrollerG7
4.2 设置 closeFactor
在 ComptrollerG7.sol 合约调用 _setCloseFactor,
参数 uint newCloseFactorMantissa,这里设置为 50%,即:0.5 * 1 ^18 = 500000000000000000
4.3 设置 liquidationIncentiveMantissa
在 ComptrollerG7.sol 合约调用 _setLiquidationIncentive,
参数 uint newLiquidationIncentiveMantissa,设置流动性激励为 8%,参数值就是1.08 * 1 ^ 18 = 1080000000000000000
4.4 设置 oracle
在 ComptrollerG7.sol 合约调用 _setPriceOracle,
参数 PriceOracle newOracle,这里设置为 SimplePriceOracle.sol 地址:0x5991199a9aB1801A229a2E49a042471eDE997a21
五、interestRate 模块
5.1 部署 JumpRateModelV2.sol
部署时参数:
- uint baseRatePerYear, 实际设置为 0
- uint multiplierPerYear, 实际设置 7%, 即 0.07 * 10 ^ 18 = 70000000000000000
- uint jumpMultiplierPerYear, 实际设置 3, 即 3 * 10 ^ 18 = 3000000000000000000
- uint kink_, 实际设置 75%, 即 0.75 * 10 ^ 18 = 750000000000000000
- address owner_, 实际设置 msg.sender
使用 account1 账号进行部署,成功:
contract address: 0x8A517DA790929D2aC3527210f9472E2822424180
备注: 部署后, 参数都可以用 updateJumpRateModel 方法进行修改;
5.2 部署另一个 JumpRateModelV2.sol
如果是测试,只需要部署一个就可以了,使用erc20的,
因为 cToken 跟 JumpRateModelV2 需要一一对应的关系,因此再次部署该合约,用于后面分别与 CErc20Delegator.sol 和 CEther.sol 对应.
部署时参数跟5.1节相同;
使用 account1 账号进行部署,成功:
contract address: 0x0cca4ccD1ED542B5D7F3Ebbcf49D92DCB0a8D04e
六、CToken 模块(ERC20)
6.1 部署 ERC20Token.sol
部署一个标准 ERC20 代币,作为基础资产用于测试,
例子:使用 account1 账号进行部署usdt合约,成功:
contract address: 0xBEA207ec294BCe7a866C3a598195A61Bb7E8D599
6.2 部署 CErc20Delegate.sol
此合约给支持代理的 cToken 合约使用,不支持代理的 cToken 不需要使用这个合约;
所有 ERC20 基础资产的 CToken 采用委托代理模式,所以我们先部署一个实现合约:
使用 account1 账号进行部署,成功:
contract address: 0xc176eD65274b2a2d422126d597Be715fc97d2e98
6.3 部署 CErc20Delegator.sol
此合约即为与代币类型(ERC20)的标的资产对应的 cToken 合约;
部署时参数:
- address underlying_, erc20标的资产地址,见6.1节
- ComptrollerInterface comptroller_, ComptrollerG7.sol 合约地址,见2.2节
- InterestRateModel interestRateModel_, JumpRateModelV2合约地址,见5.1节
- uint initialExchangeRateMantissa_, 初始汇率,按 1:1 设置,比列见备注说明,本文 1 * 10 ^ 18 = 100000000000000000
- string memory name_, cToken 的 name COMPOUND USD
- string memory symbol_, cToken 的 symbol cUSD
- uint8 decimals_, cToken 的 decimals, 设为 18
- address payable admin_, 应该是时间锁定合约地址,此处设为 msg.sender
- address implementation_, CErc20Delegate 合约地址,见6.2节
- bytes memory becomeImplementationData, 额外初始数据,此处填入0x;即无数据
备注:initialExchangeRateMantissa_ = 1 * 10 ^ (18 + underlyingDecimals - cTokenDecimals)
使用 account1 账号进行部署,成功:
contract address: 0x209C9b6a0Ec37b91d0758514070A8439B14B9B3c
七、CToken 模块(ETH)
7.1 部署 CEther.sol
此合约即为与主币类型(ETH)对应的 cToken 合约,
部署时参数:
- ComptrollerInterface comptroller_, unitroller合约地址,见2.1节
- InterestRateModel interestRateModel_, JumpRateModelV2合约地址,见5.2节
- uint initialExchangeRateMantissa_, 初始汇率,按 1:1 设置,本文 1 * 10 ^ 18 = 100000000000000000
- string memory name_, cToken 的 name COMPOUND ETHER
- string memory symbol_, cToken 的 symbol cETH
- uint8 decimals_, cToken 的 decimals,设为 18
- address payable admin_, 设为 msg.sender
使用 account1 账号进行部署,成功:
contract address: 0xf3feeab27E8B8b71ED92040be19E5aA80baf9B01
八、设置市场价格
在SimplePriceOracle.sol合约里调用setUnderlyingPrice:
8.1设置cUSD的价格:
CToken cToken, CErc20Delegator.sol 地址
uint underlyingPriceMantissa, 1 * 10 ^ 18 = 1000000000000000000
使用 account1 账号进行cUSD价格设置操作,成功
8.2设置cETH的价格:
CToken cToken, CEther.sol 地址
uint underlyingPriceMantissa, 2000 * 10 ^ 18 = 2000000000000000000000
使用 account1 账号进行cETH价格设置操作,成功
九、CToken 配置
9.1 设置 ReserveFactor
设置保证金系数
9.1.1 在 CErc20Delegator.sol 调用合约方法 _setReserveFactor:
设置时参数:
- uint newReserveFactorMantissa , 新的保证金系数, 本文 0.1 * 10 ^ 18 = 100000000000000000
9.1.2 在 CEther.sol 调用合约方法 _setReserveFactor:
设置时参数:
- uint newReserveFactorMantissa , 新的保证金系数, 本文 0.2 * 10 ^ 18 = 200000000000000000
9.2 CToken 加入市场
在 ComptrollerG7.sol 调用合约方法 _supportMarket:
设置时参数:
- CToken cToken, CErc20Delegator.sol 或 CEther.sol 地址
本文操作两次,将前面部署的 CErc20Delegator.sol 和 CEther.sol 均加入;
使用 account1 账号进行操作
9.3 设置 CollateralFactor
设置抵押率;
在 ComptrollerG7.sol 调用合约方法 _setCollateralFactor:
设置时参数:
- CToken cToken, CErc20Delegator.sol 地址
- uint newCollateralFactorMantissa, 抵押率,本文使用 0.6 * 10 ^ 18 = 600000000000000000
使用 account1 账号进行操作,成功
Remix部署完合约以后,如下图:
十、COMP奖励
用户存和借cToken都会有奖励,如果cToken市场设置了compSpeed。
compSpeed: 整数,表示协议将COMP分配给市场供应商或借款人的速率。价值是分配给市场的每个区块的COMP(单位:wei)。 请注意,并非每个市场都向其参与者分发了COMP。可以设置成0。速度表明市场供应商或借款人获得了多少红利,因此将这个数字翻一番,可以显示市场供应商和借款人获得的红利之和。
//代码示例实现了读取每个以太坊区块分配到单个市场的COMP量。
/**
* @notice Set COMP speed for a single market
* @param cToken The market whose COMP speed to update
* @param compSpeed New COMP speed for market
*/
function _setCompSpeed(CToken cToken, uint compSpeed) public {
require(adminOrInitializing(), "only admin can set comp speed");
setCompSpeedInternal(cToken, compSpeed);
}
owner才可以设置
参数
- cToken 相应的市场cToken地址
- compSpeed 价值是分配给市场的每个区块的COMP(单位:wei)
这个方法不执行,默认为0,不分发comp
计算compspeed:需要翻倍计算
const cTokenAddress = '0xabc...';
const comptroller = new web3.eth.Contract(comptrollerAbi, comptrollerAddress);
let compSpeed = await comptroller.methods.compSpeeds(cTokenAddress).call();
compSpeed = compSpeed / 1e18;
// COMP issued to suppliers OR borrowers
const compSpeedPerDay = compSpeed * 4 * 60 * 24;
// COMP issued to suppliers AND borrowers
const compSpeedPerDayTotal = compSpeedPerDay * 2;
十一 提取(Claim COMP)
每个 Compound 用户都会为他们提供给协议或从协议中借用的每个区块累积COMP。用户可以随时调用Comptroller的 claimComp 方法,将累积的COMP转移到他们的地址。
合约方法:
// Claim all the COMP accrued by holder in all markets
function claimComp(address holder) public
// Claim all the COMP accrued by holder in specific markets
function claimComp(address holder, CToken[] memory cTokens) public
// Claim all the COMP accrued by specific holders in specific markets for their supplies and/or borrows
function claimComp(address[] memory holders, CToken[] memory cTokens, bool borrowers, bool suppliers) public
可以使用claimComp()方法提取个人的comp奖励
const comptroller = new web3.eth.Contract(comptrollerAbi, comptrollerAddress);
await comptroller.methods.claimComp("0x1234...").send({ from: sender });
测试模块
前面我们部署了comptroller合约,现在我们需要写一部分测试,看具体的合约逻辑执行。在最小可运行的compound合约中,我们部署了抵押usd,以及compound铸造出来的token:cUSD. 并部署了cUSD实际调用的逻辑cErc20Delegate, 然后cUSD的借贷模型中采用的是JumpRateModelV2,对应的审计合约是comptrollerG7.
下面我们分别就compound中,最核心的用户交互逻辑来编写5个测试,简单验证逻辑可行性。
1、存 mint
用户向compound中存款的逻辑是:用户向compound中存入USD代币, compound根据当前的汇率算出铸造的cUSD代币数量,将对应的cUSD代币转账给用户。
用户函数:enterMarkets
用户的地址中对应用户的所有资产列表,当计算一个用户的所有流动性时。在借贷一种资产前,一个或者多种资产必须被提供给compound以用作抵押。在借贷发生前,任何借贷出的资产必须通过这种方式添加进入compound中。该函数的返回值是一个列表,即该用户的所有资产列表。
在 ComptrollerG7.sol 调用合约方法 enterMarkets:
参数:cTokens: [
"0x209C9b6a0Ec37b91d0758514070A8439B14B9B3c", // cUSD 地址
"0xf3feeab27E8B8b71ED92040be19E5aA80baf9B01" // cETH 地址
]
ComptrollerG7(address(unitroller)).enterMarkets(addrs);
//此时alice调用enterMarkets后,全局变量accountAssets[alice] = cToken[cUni], markets[cWALKER]={true, 60%,{alice:true},false}
//alice 调用cWALKER的mint方法
WALKER.approve(address(cWALKER),uint(-1));
cWALKER.mint(200000000000000000000); //200
// 200000000000000000000/1000000000000000000 //1:1
cWALKER.balanceOf(alice) = 200000000000000000000; //200
cWALKER.totalSupply() = 200000000000000000000
cWALKER.getCash() = 200000000000000000000
cWALKER.supplyRatePerBlock() = 0 //此时没有借款,利用率为0
ComptrollerG7(address(unitroller)).getAccountLiquidity(alice) = 120000000000000000000 = 200000000000000000000 * 0.6 // 120 用户流动性:为UnderlyingToken * 0.6 * price
2、借 borrow
借币逻辑是:用户在compound中有多种cToken资产,记录在accountAssets中。然后用户向compound借出一定量的usd资产,同时增加用户的负债额度。compound在接受用户的借款请求时,首先会检查cToken有没有上市,再检查用户是否enterMarket,然后根据现在的预言机报价检查用户的账户流动性。
//alice 在compound中存入了200000000000000000000的WALKER代币,获得了200000000000000000000的cWALKER代币
//alice 向compound提出借款50000000000000000000的WALKER代币
cWALKER.borrow(50000000000000000000);//50
cWALKER.totalBorrows() = 50000000000000000000;//50
cWALKER.getCash() == 150000000000000000000 = 200000000000000000000 - 50000000000000000000//150
cWALKER.supplyRatePerBlock = 2219685438
cWALKER.exchangeRateStored() = 1000000000000000000
cWALKER.borrowRatePerBlock() = 11098427194
利用率:utilization = cWALKER.supplyRatePerBlock / cWALKER.borrowRatePerBlock * (1- 0.25) =
cWALKER.borrowIndex() = 1000000000000000000
cWALKER.accrualBlockNumber() = 18301817
3、还 repay
repay操作是borrow的逆操作,可以通过repayBorrow偿还自己的贷款,repayBorrowBehalf代为偿还他人贷款,其具体逻辑是用户批准cToken合约使用其underlying token,先调用accuralInterest计算目前利率指数和对全部借贷额计息,然后调用comptroller.repayBorrowAllowed函数检查是否可以偿还,最后调用repayBorrowFresh偿还。
WALKER.approve(cWALKER, 50000000000000000000);
cWALKER.repayBorrow(50000000000000000000);
cWALKER.totalBorrows() = 129296676810100;
cWALKER.getCash() = 200000000000000000000;//200
cWALKER.supplyRatePerBlock = 0
cWALKER.exchangeRateStored() = 1000000517186707240
cWALKER.borrowRatePerBlock() = 28699
利用率:utilization = cWALKER.supplyRatePerBlock / cWALKER.borrowRatePerBlock * (1- 0.25) =
cWALKER.borrowIndex() = 1000002585933536202
cWALKER.accrualBlockNumber() = 18302050
4、取 redeem
redeem是mint的逆运算,但在实际逻辑中,增加了一个检查账户虚拟流动性的一项。用户可以调用redeem来偿还给定数量的cToken,或者调用redeemUnderlying来偿还某数量的cToken得到给定数量的underlying Token. redeem操作的步骤是用户批准cUSD合约使用用户的cUSD代币,然后调用accuralInterest函数,来计算最新的利率指数Index,并对totalBorrows计息。再然后是调用comptroller.redeemAllowed函数,计算用户的虚拟流动性,看是否用户有足够的流动性来取走token。最后是redeemFresh函数根据要取走的数值,更新accountBorrow中的数值和totalBorrows。
5、清算 liquidity
发生清算的一种典型情况是,用户enterMarkets了两个market,分别是cUni和cUSDT资金池。然后用户在cUni池中,存入Uni获得一定的cUni。用户凭借cUni在cUSDT资金池中借贷出USDT。然而,由于Uni的价格波动,导致Uni/USDT的价格突然下跌,此时用户放置在cUni池中的cUni的总价值小于了借出的USDT的价值,从而触发外部清算者进行清算。
清算过程整体分为两部分:第一部分是repayBorrower部分,代为偿还underlying token,另一部分是seize部分,即将被清算者的cToken及奖励金一起奖励给清算者。由于清算涉及到两种cToken,故在清算的第一步是分别调用两种cToken的accural Interest函数,计算各自最新的利率指数Index,并计算含息债务总额。然后调用comptroller.liquidateBorrowAllowed函数,计算被清算账户的流动性,如果被清算账户的流动性为正,则不允许清算,如果被清算账户的流动性为负,并验算单笔交易的清算量不能超过被清算账户的最大可清算量,则允许清算。具体清算时,要求清算者不能是被清算者自己,然后计算转给被清算者的cToken数量。
在执行转账cToken到清算者之前,需调用comptroller.seizeAllowed函数,作用是验证调用seize函数的msg.sender和address(this)的comptroller保持一致。然后将清算者的账户余额加上seizeTokens,被清算者的余额减去seizeTokens。在完成seize部分后,函数跳转到repayBorrow部分,代为偿还underlying token。
具体清算的概念,可以看清算概述,比较详细简单。
前端调用的方法
CErc20Delegator.sol是给普通erc20用的,CEther.sol是给链的主币用的,基础的都是cToken。
里面的mint,redeem,redeemUnderlying,borrow,repayBorrow,repayBorrowBehalf都类似的。
/**
* @notice Sender supplies assets into the market and receives cTokens in exchange
* @dev Accrues interest whether or not the operation succeeds, unless reverted
* @param mintAmount The amount of the underlying asset to supply
* @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
*/
function mint(uint mintAmount) external returns (uint) {
bytes memory data = delegateToImplementation(abi.encodeWithSignature("mint(uint256)", mintAmount));
return abi.decode(data, (uint));
}
/**
* @notice Sender redeems cTokens in exchange for the underlying asset
* @dev Accrues interest whether or not the operation succeeds, unless reverted
* @param redeemTokens The number of cTokens to redeem into underlying 将被赎回的cToken的数量
* @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
* redeem 方法将指定数量的 cToken 转换为标的资产,并将其返还给用户。收到的标的数量等于赎回的 cToken 数量乘以当前汇率。
* 赎回额必须小于用户的账户流动性和市场可用的流动性。
*/
function redeem(uint redeemTokens) external returns (uint) {
return redeemInternal(redeemTokens);
}
/**
* @notice Sender redeems cTokens in exchange for a specified amount of underlying asset
* @dev Accrues interest whether or not the operation succeeds, unless reverted
* @param redeemAmount The amount of underlying to redeem 将被赎回的标的的资产数量
* @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
* redeem underlying 方法将 cToken兑换成指定数量的标的资产,并返回给用户。赎回的 cToken的数量等于收到的标的数量除以当前汇率。
* 赎回额必须小于用户的账户流动性和市场可用的流动性。
*/
function redeemUnderlying(uint redeemAmount) external returns (uint) {
return redeemUnderlyingInternal(redeemAmount);
}
/**
* @notice Sender borrows assets from the protocol to their own address
* @param borrowAmount The amount of the underlying asset to borrow
* @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
* borrow 方法将协议中的标的资产转移给用户,并创建一个借款余额,根据该资产的借款利率开始累积利息。
* 借款额必须小于用户的账户流动性和市场可用的流动性。
*/
function borrow(uint borrowAmount) external returns (uint) {
return borrowInternal(borrowAmount);
}
/**
* @notice Sender repays their own borrow
* @dev Reverts upon any failure
* repay 方法将标的资产转移到协议中,并减少用户的借款余额。
*/
function repayBorrow() external payable {
(uint err,) = repayBorrowInternal(msg.value);
requireNoError(err, "repayBorrow failed");
}
/**
* @notice Sender repays a borrow belonging to borrower
* @dev Reverts upon any failure
* @param borrower the account with the debt being payed off
*/
function repayBorrowBehalf(address borrower) external payable {
(uint err,) = repayBorrowBehalfInternal(borrower, msg.value);
requireNoError(err, "repayBorrowBehalf failed");
}
在 ComptrollerG7.sol 调用合约方法 enterMarkets:
参数:cTokens: [
"0x209C9b6a0Ec37b91d0758514070A8439B14B9B3c", // cUSD 地址
"0xf3feeab27E8B8b71ED92040be19E5aA80baf9B01" // cETH 地址
]
ComptrollerG7(address(unitroller)).enterMarkets(addrs);
参考: