本文作者:ripwu[1]
之前在看 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[2] 交易,将存入的 cETH 退出市场
然后用账户 A 发送 setUnderlyingPrice[3] 交易,操纵预言机,模拟市场价格波动,使得账户 A 资不抵债
最后用账户 B 发送 liquidateBorrow[4] 交易,清算账户 A 的债务,指定以 cETH 为抵押物
结论是:退出市场的 cETH 确实可以被清算
问题
问题来了:
问题一:已经退出市场的资产,是否应该被清算?
问题二:如果不应该被清算,那么进入市场和退出市场的逻辑,意义何在?
综合考虑,我个人觉得 Compound 原意应该是不允许清算已退出市场的资产;理由如下:
首先,用户在实际存款前必须单独发起进入市场的交易,考虑到 Compound 在以太坊主网运营,交易手续费不可忽视
如果可以被清算,那么进入和退出市场的逻辑没有什么实际用途,在代码中也未找到其他用途
其次,在退出市场前,Compound 提示如下
但是,从另外一个角度来说,退出市场的资产,确实应该支持被清算,否则有损于系统健康度
反馈
两个角度都有道理,我没想明白,于是向 Compound 发送了邮件,一周后收到了回复:问题已知,已退出市场的资产可以被清算;提示文本看起来是有误导
不过,我还是没明白:既然可以被清算,为什么要设计进入退出的功能,用户专门发起这两笔交易的手续费呢 ...
BTW,前两天 Aave V3 似乎也引入了 资产隔离 [5] 的概念 ..
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 价格波动时,可能会导致一些问题,比如 这个提案 [6] 描述的例子:
假设 USDC 因监管或其他原因不断下跌,比如市场价格为 0.5 美元,而 Compound 仍认为其价值 1 美元
由于存在价差,我们可以从外部市场低价借入 USDC,存入 Compound,将其高价抵押借出其他资产
造成的结果是,市场价格不断下跌的 USDC 涌入 Compound,而其他资产被不断借出
提案提出的问题,已经过去几个月了,没有得到官方回复 ..
抵押率 与 清算阈值
在比较 Compound 和 Aave 时,我发现 Compound 没有 Aave 清算阈值 (Liquidation Threshold)[7] 的概念
在用户体验上,这可能会带来一些问题:
如果用户在 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 代币和价格预言 [8]
又如,官方仓库中 Comptroller,似乎也是较老的版本 [9];而主网实际使用的合约,是修复了 9 月底 COMP 安全事件的版本 [10]
-- 对于新入手 Compound 的开发者而言,要找到正确的代码,只能求助于 EtherScan 和搜索引擎,体验有点糟糕
更重要的是,会导致接下来的问题:
不同步的主网与测试网络
考虑到链下数据不好维护,为了便于测试,可以在测试网使用模拟预言机作为 mock
除此之外,应该尽可能保证其他合约在主网和测试网一致,但在 Compound 中并非如此:
比如,最核心的 Unitroller,在 主网 [11] 与 测试网络 [12] 上部署的代码版本不同
又如 CErc20Immutable 是旧代码,会导致 cToken 无法支持社区治理 [13]。主网中这个合约已被废弃,但在测试中仍在使用,比如 Ropsten 中的 cUSDC[14]
-- 主网与测试网络之间的不同步,除了削弱测试网络的意义,也增加了新开发者的理解成本
要解决这个问题,首先要解决前面的问题,确保官方仓库与主网部署的合约代码一致
这也就引出了更关键的问题:
测试网络似乎没有发生作用
COMP 安全事件 [15] 暴露的问题比较严重:考虑到除了公开的测试网络之外,社区中还有不少开发者搭建着私人测试网络,而理论上,这个问题是必现的;
我们似乎可以得出一个结论:Compound 的测试网络和 测试代码 [16],没有起到作用
那么,Compound 协议安全如何保证呢?社区成员似乎也在担心,比如最近几天出现的提案 Auditing Compound Protocol[17],Continuous Formal Verification[18]
-- 另外,还有代码与文档 / 产品之间的不同步,原始的升级模式等;限于个人视野未知全貌,某些理解可能存在局限,因此不做展开
以上,一家之言,欢迎指正~
参考资料
[1]
ripwu:https://learnblockchain.cn/people/3911
[2]
exitMarket:https://ropsten.etherscan.io/tx/0x7b71d5cf083eca8ab436126953f87573fb9d047dced373394ba2d6ae4621e0a2
[3]
setUnderlyingPrice:https://ropsten.etherscan.io/tx/0xbb4691fdf1f81b9634375658862d7b7ec6ff7253e81f3896a6025bba11b1e54c
[4]
liquidateBorrow:https://ropsten.etherscan.io/tx/0xa38099eb44664169e41e36d06ef0d72c241ddd0a4349e3f36f46506667c4c975
[5]
资产隔离 :https://governance.aave.com/t/introducing-aave-v3/6035
[6]
这个提案 :https://www.comp.xyz/t/floating-stablecoin-prices/2005
[7]
Aave 清算阈值 (Liquidation Threshold):http://godorz.info/2021/10/aave-v2/#i-19
[8]
Compound 代币和价格预言 :https://godorz.info/2021/11/compound_comp_and_price_oracles/#i-8
[9]
较老的版本 :https://github.com/compound-finance/compound-protocol/blob/master/contracts/ComptrollerStorage.sol
[10]
版本 :https://etherscan.io/address/0xbafe01ff935c7305907c33bf824352ee5979b526#code
[11]
主网 :https://etherscan.io/address/0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B#readProxyContract
[12]
测试网络 :https://ropsten.etherscan.io/address/0xcfa7b0e37f5AC60f3ae25226F5e39ec59AD26152#readProxyContract
[13]
无法支持社区治理 :https://www.comp.xyz/t/legacy-market-migration-wbtc/1333
[14]
cUSDC:https://ropsten.etherscan.io/address/0x2973e69b20563bcc66dC63Bde153072c33eF37fe#code
[15]
COMP 安全事件 :https://github.com/rebase-network/Dapp-Learning/blob/main/defi/Compound/contract/[事件分析] 9月29日 Compound 62号提案 所引发的可怕Bug.md
[16]
测试代码 :https://github.com/compound-finance/compound-protocol/tree/master/spec
[17]
Auditing Compound Protocol:https://www.comp.xyz/t/auditing-compound-protocol/2543
[18]
Continuous Formal Verification:https://www.comp.xyz/t/continuous-formal-verification/2557
免责声明:作为区块链信息平台,本站所发布文章仅代表作者个人观点,与链闻 ChainNews 立场无关。文章内的信息、意见等均仅供参考,并非作为或被视为实际投资建议。
原创文章,作者:币圈吴彦祖,如若转载,请注明出处:https://www.kaixuan.pro/news/6277/