Compound 潜在风险和改进
之前在看 Compound 代码时,感觉存在一些疑问和改进
其中有个疑问昨天得到了回复,趁着这个机会简单整理下笔记
退出市场的资产,仍可被清算
背景
// compound-protocol/contracts/Comptroller.sol
function borrowAllowed(address cToken, address borrower, uint borrowAmount) external returns (uint) {
if (!markets[cToken].accountMembership[borrower]) {
// only cTokens may call borrowAllowed if borrower not in market
require(msg.sender == cToken, "sender must be cToken");
// attempt to add borrower to the market
Error err = addToMarketInternal(CToken(msg.sender), borrower);
if (err != Error.NO_ERROR) {
return uint(err);
}
// it should be impossible to break the important invariant
assert(markets[cToken].accountMembership[borrower]);
}
}
function addToMarketInternal(CToken cToken, address borrower) internal returns (Error) {
Market storage marketToJoin = markets[address(cToken)];
if (!marketToJoin.isListed) {
// market is not listed, cannot join
return Error.MARKET_NOT_LISTED;
}
if (marketToJoin.accountMembership[borrower] == true) {
// already joined
return Error.NO_ERROR;
}
// survived the gauntlet, add to list
// NOTE: we store these somewhat redundantly as a significant optimization
// this avoids having to iterate through the list for the most common use cases
// that is, only when we need to perform liquidity checks
// and not whenever we want to check if an account is in a particular market
marketToJoin.accountMembership[borrower] = true;
accountAssets[borrower].push(cToken);
emit MarketEntered(cToken, borrower);
return Error.NO_ERROR;
}
Compound 在借款时会通过 borrowAllowed()
检查用户是否已经进入 cToken
市场
如果未进入,会调用 addToMarketInternal()
将 cToken
添加到用户接触的资产列表 accountAssets[borrower]
中
我查了下 accountAssets[borrower]
,似乎只在 存款,借款,和计算用户健康度时使用
其中前面两个操作 (存款,借款) 更多是类似声明的逻辑,没有什么疑点
// compound-protocol/contracts/Comptroller.sol
function getHypotheticalAccountLiquidityInternal(
address account,
CToken cTokenModify,
uint redeemTokens,
uint borrowAmount) internal view returns (Error, uint, uint) {
// For each asset the account is in
CToken[] memory assets = accountAssets[account];
for (uint i = 0; i < assets.length; i++) {
CToken asset = assets[i];
// Too Long Not Listed.
// ...
}
}
用户健康度计算代码如上,在计算 account
健康度时,遍历的是 accountAssets[account]
如果用户此前发起退出某个资产市场的交易,如 USDC,则这个资产不在 accountAssets[account]
中
这时,计算健康度会跳过用户的 USDC 资产
清算
上面梳理了背景逻辑,即:退出市场的资产,不会参与清算时用户健康度的计算
内在含义是:该资产可以作为存款收取利息,但由于退出了市场,不会做为抵押物
而在实际清算代码时,我没有找到有关清算交易指定的资产,是否不在用户的 accountAssets
列表中的判断
即已经退出市场,不会作为抵押物的资产,可以被清算..
// compound-protocol/contracts/CToken.sol
function liquidateBorrowFresh(address liquidator, address borrower, uint repayAmount, CTokenInterface cTokenCollateral) internal returns (uint, uint) {
/* Fail if repayBorrow fails */
(uint repayBorrowError, uint actualRepayAmount) = repayBorrowFresh(liquidator, borrower, repayAmount);
if (repayBorrowError != uint(Error.NO_ERROR)) {
return (fail(Error(repayBorrowError), FailureInfo.LIQUIDATE_REPAY_BORROW_FRESH_FAILED), 0);
}
/* We calculate the number of collateral tokens that will be seized */
(uint amountSeizeError, uint seizeTokens) = comptroller.liquidateCalculateSeizeTokens(address(this), address(cTokenCollateral), actualRepayAmount);
require(amountSeizeError == uint(Error.NO_ERROR), "LIQUIDATE_COMPTROLLER_CALCULATE_AMOUNT_SEIZE_FAILED");
/* Revert if borrower collateral token balance < seizeTokens */
require(cTokenCollateral.balanceOf(borrower) >= seizeTokens, "LIQUIDATE_SEIZE_TOO_MUCH");
// If this is also the collateral, run seizeInternal to avoid re-entrancy, otherwise make an external call
uint seizeError;
if (address(cTokenCollateral) == address(this)) {
seizeError = seizeInternal(address(this), liquidator, borrower, seizeTokens);
} else {
seizeError = cTokenCollateral.seize(liquidator, borrower, seizeTokens);
}
return (uint(Error.NO_ERROR), actualRepayAmount);
}
测试
我担心存在理解偏差,于是在 Ropsten 网络上进行了测试:
首先用账户 A 发送 exitMarket 交易,将存入的 cETH 退出市场
然后用账户 A 发送 setUnderlyingPrice 交易,操纵预言机,模拟市场价格波动,使得账户 A 资不抵债
最后用账户 B 发送 liquidateBorrow 交易,清算账户 A 的债务,指定以 cETH 为抵押物
结论是:退出市场的 cETH 确实可以被清算
问题
问题来了:
问题一:已经退出市场的资产,是否应该被清算?
问题二:如果不应该被清算,那么进入市场和退出市场的逻辑,意义何在?
综合考虑,我个人觉得 Compound 原意应该是不允许清算已退出市场的资产;理由如下:
首先,用户在实际存款前必须单独发起进入市场的交易,考虑到 Compound 在以太坊主网运营,交易手续费不可忽视
如果可以被清算,那么进入和退出市场的逻辑没有什么实际用途,在代码中也未找到其他用途
其次,在退出市场前,Compound 提示如下
但是,从另外一个角度来说,退出市场的资产,确实应该支持被清算,否则有损于系统健康度
反馈
两个角度都有道理,我没想明白,于是向 Compound 发送了邮件,一周后收到了回复:问题已知,已退出市场的资产可以被清算;提示文本看起来是有误导
不过,我还是没明白:既然可以被清算,为什么要设计进入退出的功能,用户专门发起这两笔交易的手续费呢...
BTW,前两天 Aave V3 似乎也引入了 资产隔离 的概念..
USDC 钉住 1 美元
前面文章中有举例说明 Compound 价格预言机的流程,以 DAI 为例:首先向 USDC-WETH 交易对查询 WETH 价格,然后向 DAI-WETH 交易对查询 DAI 价格,最后将两者相乘,得到以 USDC 计价的 DAI 价格
换句话说,Compound 中大部分 token 的价格是以 USDC 计价的
这里隐藏了一个假设,USDC 价格是恒定不变的,可以作为计价单位
// https://github.com/smartcontractkit/open-oracle/blob/master/contracts/Uniswap/UniswapAnchoredView.sol
function priceInternal(TokenConfig memory config) internal view returns (uint) {
if (config.priceSource == PriceSource.REPORTER) return prices[config.symbolHash].price;
// config.fixedPrice holds a fixed-point number with scaling factor 10**6 for FIXED_USD
if (config.priceSource == PriceSource.FIXED_USD) return config.fixedPrice;
if (config.priceSource == PriceSource.FIXED_ETH) {
uint usdPerEth = prices[ethHash].price;
require(usdPerEth > 0, "ETH price not set, cannot convert to dollars");
// config.fixedPrice holds a fixed-point number with scaling factor 10**18 for FIXED_ETH
return mul(usdPerEth, config.fixedPrice) / ethBaseUnit;
}
}
实现上,Compound 对 USDC,USDT 等做了特殊处理,其 priceSource
配置为 FIXED_USD
,钉在 1 美元
在 USDC 价格波动时,可能会导致一些问题,比如 这个提案 描述的例子:
假设 USDC 因监管或其他原因不断下跌,比如市场价格为 0.5 美元,而 Compound 仍认为其价值 1 美元
由于存在价差,我们可以从外部市场低价借入 USDC,存入 Compound,将其高价抵押借出其他资产
造成的结果是,市场价格不断下跌的 USDC 涌入 Compound,而其他资产被不断借出
提案提出的问题,已经过去几个月了,没有得到官方回复..
抵押率 与 清算阈值
在比较 Compound 和 Aave 时,我发现 Compound 没有 Aave 清算阈值 (Liquidation Threshold) 的概念
在用户体验上,这可能会带来一些问题:
如果用户在 Compound 按最大抵押率借款,只要市场价格稍有波动,其抵押资产就会面临清算风险
// compound-protocol/contracts/Comptroller.sol
function getHypotheticalAccountLiquidityInternal(
address account,
CToken cTokenModify,
uint redeemTokens,
uint borrowAmount) internal view returns (Error, uint, uint) {
AccountLiquidityLocalVars memory vars; // Holds all our calculation results
uint oErr;
// For each asset the account is in
CToken[] memory assets = accountAssets[account];
for (uint i = 0; i < assets.length; i++) {
CToken asset = assets[i];
vars.collateralFactor = Exp({mantissa: markets[address(asset)].collateralFactorMantissa});
// Pre-compute a conversion factor from tokens -> ether (normalized price value)
vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);
// sumCollateral += tokensToDenom * cTokenBalance
vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom, vars.cTokenBalance, vars.sumCollateral);
}
}
其中,在计算 sumCollateral
时,使用的是抵押率 collateralFactor
--
与之相对的,在 Aave 中,贷款时按抵押率计算,而清算时健康度按清算阈值计算;因为清算阈值比抵押率大,因此留出了安全垫
引用链接中的例子:用户抵押价值 2 ETH 的资产,借出 1.575 ETH 的债务,此时健康度为 1.0476
注意例子中的债务,是按资产的最大抵押率借出的;在这种情况下,可以忍受市场价格小范围的波动
比如,市场价格短期波动,导致债务上涨 3% 时,此时健康度仍在 1 以上,用户资产不会面临清算风险
隐患
不在官方仓库中的代码
比如价格预言机,还未被合并,见 Compound 代币和价格预言
又如,官方仓库中 Comptroller
,似乎也是较老的版本;而主网实际使用的合约,是修复了 9 月底 COMP 安全事件的版本
--
对于新入手 Compound 的开发者而言,要找到正确的代码,只能求助于 EtherScan 和搜索引擎,体验有点糟糕
更重要的是,会导致接下来的问题:
不同步的主网与测试网络
对于价格预言机,考虑到链下数据不好维护,为了便于测试,可以在测试网部署模拟合约作为 mock
除此之外,应该尽可能保证其他合约在主网和测试网一致,但在 Compound 中并非如此:
比如,最核心的 Unitroller
,在 主网 与 测试网络 上部署的代码版本不同
又如 CErc20Immutable
是旧代码,会导致 cToken 无法支持社区治理。主网中这个合约已被废弃,但在测试中仍在使用,比如 Ropsten 中的 cUSDC
--
主网与测试网络之间的不同步,除了削弱测试网络的意义,也增加了新开发者的理解成本
要解决这个问题,首先要解决前面的问题,确保官方仓库与主网部署的合约代码一致
这也就引出了更关键的问题:
测试网络似乎没有发生作用
COMP 安全事件 暴露的问题比较严重:考虑到除了公开的测试网络之外,社区中还有不少开发者搭建着私人测试网络,而理论上,这个问题是必现的;
我们似乎可以得出一个结论:Compound 的测试网络和 测试代码,没有起到作用
那么,Compound 协议安全如何保证呢?社区成员似乎也在担心,比如最近几天出现的提案 Auditing Compound Protocol,Continuous Formal Verification
--
另外,还有代码与文档/产品之间的不同步,原始的升级模式等;限于个人视野未知全貌,某些理解可能存在局限,因此不做展开
以上,一家之言,欢迎指正~