理解借贷协议的清算逻辑
这篇文章是借贷系列文章的二篇,本文我们将回顾了DeFi清算的运作方式,以及为什么它们很重要。
在上一篇文章中,我们回顾了Defi 借贷协议的核心概念,以及不同的协议如何选择实现这些操作,例如份额币,在这篇文章中,我们将重点讨论我们认为是最令人兴奋的Defi借贷概念之一:清算。
超额抵押和坏账
清算和清算阈值
Compound:账户流动性
Maker
AAVE V2 - 健康系数
穿仓头寸分析
你可能还记得我们以前的文章,协议用户只能用他们提供给协议的抵押品的一个可变比例来借入资产。这是有道理的,因为协议需要确保,如果你无法偿还债务,它可以从你那里收回其资产(或任何价值相同的其他资产)。这种资产抵押的过程始于传统金融服务,例如,今天,人们可以把他们的房子或兰博基尼作为偿还贷款的担保。
抵押依靠的前提是抵押品的价格保持其价值 -- 尽管房子或兰博基尼的价格不能保证,但它们各自的价值比ERC20或NFT的价值波动要小。
在大多数DeFi贷款协议中,你的抵押贷款资产必须比你的贷款有更高价值--也被称为超额抵押。
如果贷款协议想保持财务稳定,只允许超额抵押贷款,这符合贷款协议的利益。想象一下,你提供一些资产作为抵押品,而这个资产的价值突然下降到低于你从协议中贷款的资产价值。你就会缺乏偿还贷款的动力。毕竟,你在偿还贷款过程中抢救出来的抵押品现在的价值将低于你实际偿还贷款的金额。这笔贷款现在就得不到偿还。
每出现得不到偿还的贷款对其协议都是不利的。穿仓贷款产生的债务在协议中造成了不安全,毕竟,债务的数额是贷款人无法从协议中收回的资产数额。为了强调这种债务有多糟糕:如果协议上出现相当于传统金融服务的 "银行挤兑",最后从协议中提取资产的用户将没法提取资产。
很自然,有大量坏账的协议对用户的吸引力就会降低。
我们已经确定,当抵押品的价值低于借款人连本带利的债务价值时,被称为抵押不足,借款人的债务对借贷协议的健康构成威胁。为了防止抵押不足的头寸的积累,协议允许不一定是协议用户的第三方(称为清算人)来偿还抵押不足(或接近抵押不足)的借款人的债务。通过偿还抵押不足的债务,清算人有权要求索取债务人的抵押品,同时获得折扣。这个过程被称为清算。
你可能想知道:为什么协议要依靠第三方来清算不健康的头寸?毕竟,协议可以将自动清算机制植入其代码中。
发送清算交易需要耗费大量的Gas。如果协议自动发送这些昂贵的交易,由此产生的Gas成本将增加他们的运营成本,从而削弱协议利润。
此外,一个自动清算系统的设计将是非常困难的。一个协议不仅要考虑一个头寸是否应该自动清算,还要考虑何时清算,并以反映市场波动的速度进行清算。通过激励专门的第三方来清算这些头寸,将这个过程外包出去要容易得多。
清算本身并不有利可图--为了使这一过程有利可图,债务人的抵押品的价值必须高于其所欠债务。如果不能保证这一过程会给他们带来利润,清算者就不会清算头寸。
但是,一个头寸何时可以清算?这个条件是由协议决定的,由他们分配给每个资产的清算阈值(liquidation threshold)的函数确定。
有了清算阈值,时间就成了关键。正如我们所知,如果一个头寸的债务价值超过其抵押品的价值,清算这些头寸对清算人来说是无利可图的,协议会被坏账缠身。因此,安全的清算阈值为清算人提供了足够的时间,在他们达到无力偿还之前清算超额抵押的头寸。
现在我们了解了每个相关方保持头寸健康的动机,我们将展示协议如何实际执行这些机制的例子:
Compound,指的是他们在账户流动性
参数下的头寸清算阈值,由Compound 的主合约--Comptroller计算。
Comptroller 有一个名为getAccountLiquidity()
的函数,返回关于账户流动性的信息。在内部这个函数调用getHypotheticalAccountLiquidityInternal()
:
pragma solidity ^ 0.8 .13;
struct AccountLiquidityLocalVars {
uint sumCollateral;
uint sumBorrowPlusEffects;
uint cTokenBalance;
uint borrowBalance;
uint exchangeRateMantissa;
uint oraclePriceMantissa;
Exp collateralFactor;
Exp exchangeRate;
Exp oraclePrice;
Exp tokensToDenom;
}
// ...
function getHypotheticalAccountLiquidityInternal(
address account,
CToken cTokenModify,
uint redeemTokens,
uint borrowAmount) internal view returns(Error, uint, uint) {
AccountLiquidityLocalVars memory vars;
uint oErr;
CToken[] memory assets = accountAssets[account];
for (uint i = 0; i < assets.length; i++) {
CToken asset = assets[i];
(oErr, vars.cTokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) =
asset.getAccountSnapshot(account);
if (oErr != 0) {
return (Error.SNAPSHOT_ERROR, 0, 0);
}
vars.collateralFactor = Exp({
mantissa: markets[address(asset)].collateralFactorMantissa
});
vars.exchangeRate = Exp({
mantissa: vars.exchangeRateMantissa
});
vars.oraclePriceMantissa = oracle.getUnderlyingPrice(asset);
if (vars.oraclePriceMantissa == 0) {
return (Error.PRICE_ERROR, 0, 0);
}
vars.oraclePrice = Exp({
mantissa: vars.oraclePriceMantissa
});
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);
if (asset == cTokenModify) {
vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.tokensToDenom,
redeemTokens, vars.sumBorrowPlusEffects);
vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice,
borrowAmount, vars.sumBorrowPlusEffects);
}
}
if (vars.sumCollateral > vars.sumBorrowPlusEffects) {
return (Error.NO_ERROR, vars.sumCollateral - vars.sumBorrowPlusEffects, 0);
} else {
return (Error.NO_ERROR, 0, vars.sumBorrowPlusEffects - vars.sumCollateral);
}
}
我们在这里看到,这个函数的主要逻辑是在一个for-loop的范围内。这表明,一个账户的流动性是通过遍历该账户参与的所有市场来计算的。换句话说,在计算账户流动性时,所有由账户借入的资产,或用作抵押品的资产都会被考虑。
回顾一下,在我们的上一篇文章中,cTokenBalance
是用户作为抵押品的标的资产的金额。在这个例子中,我们还可以看到 borrowBalance
和一些神秘的 exchangeRateMantissa
,它们都是由 getAccountSnapshot()
返回。
在我们之前的文章中对可通用的exchangeRate
变量的讨论中:
一个任意的汇率, 如果其 exchangeRate<1,可以增加铸币量,如果exchangeRate>1,可以减少铸币量
这对exchangeRateMantissa
来说是成立的,它代表了cToken
与标的资产之间的汇率。
在我们的例子中,可以看到,在获得上面提到的3个参数后,监理公司要做的第一件事是获得它目前正在迭代的特定市场的collateralFactor(抵押品系数)
。这个 collateralFactor(抵押品系数)
信息是一个指标,表明用户可以用他们的抵押品借到多少钱。从这个定义中,我们可以假设,用户对他们存入的每一个抵押品所能借到的金额都是不同的。
这个数额在不同资产之间不同的主要原因是,在协议的眼中,每个资产都有自己的 "风险",这通常归结为一个资产的价值随着时间的推移可能有多大的波动。
Compound的治理(governance)会根据市场情况改变抵押品系数,但在任何时候,他们的抵押品系数不能超过0.9--最多只能借到你所存抵押品的90%:
pragma solidity ^ 0.8 .13;
uint internal constant collateralFactorMaxMantissa = 0.9e18; // 0.9
》 源码
然后,我们看到对oracle.getUnderlyingPrice(asset)
的调用,它调用了一个外部合约,称为Oracle。
Oracles 是借贷协议中使用的合约,用于获取以某种通用货币(通常是美元、ETH或协议使用的稳定币)计价的某种资产的价格。
现在,我们已经涵盖了所有影响单个市场的头寸健康度的因素,所以我们将写下计算单个市场的 "账户流动性(AccountLiquidity)"的方程式:
注:在Compound中,资产的价格是以美元(USD)计价的。
这是一个相当多的变量,但如果你记的"份额代币 "的Compound部分,你会看到表达式为:
简单地代表了用户的 cTokens 的标的资产价值。
此外,borrowBalance_{user}
变量,正如你在这里看到的,是用户借入资产的总余额,包括其应计利息。
因此,下面的账户流动性
方程是有意义的:
另一个为清算抵押品不足的头寸设定阈值的协议是Maker。
让我们研究一下该协议部署的处理清算的两个合约:
Dog
:在迁移到清算2.0 后部署的(正如Maker治理描述的)。这里的清算功能是bark()
。Cat
:清算1.2,bite()
。grab()
:在部署Cat合约之前,作为清算的方式使用vat
合约。让我们看一下bite()
的代码片段:
pragma solidity ^ 0.8 .13;
function bite(bytes32 ilk, address urn) external returns(uint id) {
(, uint rate, uint spot) = vat.ilks(ilk);
(uint ink, uint art) = vat.urns(ilk, urn);
require(live == 1, "Cat/not-live");
require(spot > 0 && mul(ink, spot) < mul(art, rate), "Cat/not-unsafe");
还有一个来自bark()
的类似代码片段:
pragma solidity ^ 0.8 .13;
function bark(bytes32 ilk, address urn, address kpr) external returns
(uint256 id) {
require(live == 1, "Dog/not-live");
(uint256 ink, uint256 art) = vat.urns(ilk, urn);
Ilk memory milk = ilks[ilk];
uint256 dart;
uint256 rate;
uint256 dust; {
uint256 spot;
(, rate, spot, , dust) = vat.ilks(ilk);
require(spot > 0 && mul(ink, spot) < mul(art, rate), "Dog/not-unsafe");
你可能注意到,两者都有相同的不安全信息。因此,每个清算函数对金库安全的要求是相同的,这可以用这个等式表示:
我们可以用它来定义需要保持的不等式,以使金库(Maker对头寸的称呼)仍然是安全的:
写得更漂亮一些:
我们建议读者绕道去看看MakerDAO 词汇表,它扩展了我们提供的关于整个Maker生态系统的不同变量名称和术语的信息。
另外,你也可以相信我们在这里概述的内容:
spot_{ilk}
在不等式中被用作抵押品的价格,以DAI为单位并除以抵押品的清算率(由governance合约决定)。
ink_{urn}
是头寸的抵押品余额。
rate_{ilk}
是特定抵押品类型的累积债务。当与art_{urn}
(即一个头寸借入的债务额)相乘,我们可以得到DAI的总债务。
为了简化我们刚才的内容,不使用Maker的术语,方程是这样的:
注意:Maker使用DAI -- 该协议自己的稳定币 -- 来表示抵押品和债务的价值。
AAVE V2 也定义了他们自己的阈值,健康系数(HealthFactor)
。一个健康系数值为H_{f}
<1的用户可以被清算。
这里定义了:
pragma solidity ^ 0.8 .13;
vars.healthFactor = calculateHealthFactorFromBalances(
vars.totalCollateralInETH,
vars.totalDebtInETH,
vars.avgLiquidationThreshold
);
// ...
/**
* @dev Calculates the health factor from the corresponding balances
* @param totalCollateralInETH The total collateral in ETH
* @param totalDebtInETH The total debt in ETH
* @param liquidationThreshold The avg liquidation threshold
* @return The health factor calculated from the balances provided
**/
function calculateHealthFactorFromBalances(
uint256 totalCollateralInETH,
uint256 totalDebtInETH,
uint256 liquidationThreshold
) internal pure returns(uint256) {
if (totalDebtInETH == 0) return uint256(-1);
return (totalCollateralInETH.percentMul(liquidationThreshold)).wadDiv(totalDebtInETH);
}
很明显,当用户没有债务时,他们的头寸不能被清算,所以健康系数
默认为type(uint256).max
。
否则,"健康系数"定义为:
注:AAVE V2以ETH表示其抵押品和债务价值。
其中 "LiquidationThreshold "是通过治理为每个资产独立定义的,这项任务目前委托给Gauntlet,它为协议提供所有风险参数,包括 "LiquidationThreshold"。
现在我们已经涵盖了坏账的概念,我们将提供一个真实世界的例子来说明其重要性
我们将涉及的头寸是AAVE V2的以下账户:0x227cAa7eF6D955A92F483dB2BD01172997A1a623
。
让我们开始调查它的现状,通过调用AAVE V2借贷协议上的getUserAccountData
函数:
现在让我们把上面看到的东西分解一下,体会一下这个仓位的水下情况有多严重:
totalDebtETH
):17.83508595148699ethtotalCollateralETH
):0.013596360502551568 eth这就是我们需要了解的,这个仓位有问题--抵押品的价值对应其所产生的债务的一个微不足道的部分。
但是,这些头寸是如何达到这种状态的呢?
为了回答这个问题,我们可以看看这个用户在AAVE上执行的最新操作:
看起来一切都很好,直到13514857
区块,其中用户从AAVE借用了一些资产。让我们看看他们做了什么:
债务人借了700,000 MANA,快速检查一下MANA的美元价格会发现,价格是:
每MANA单位0.00032838ETH。
所以通过一些简单的乘法,我们知道这个用户对协议的债务增加了:
0.00032838 * 700000 = 229.866 ETH
也值得回顾一下这个区块的ETH的美元价格这里,它是4417.40美元。
注意上图中13517657
区块发生的入金操作,就在借款后的几个小时。
让我们看看市场上是否有什么东西动摇了用户的信心:
➜~cast call - b 13517657 0xA50ba011c48153De246E5192C8f9258A2ba79Ca9 "getAssetPrice(address)"
0x0F5D2fB29fb7d3CFeE444a200298f468908cC942
0x000000000000000000000000000000000000000000000000000131d14dce4400
以上是一个发送到AAVE V2 Price Oracle的RPC调用,以获得指定区块的1单位MANA的价值,单位为Wei。
如果我们用这个数据转换前述的价格,我们可以看到发生了什么:
0.00033625 * 700000 = 235.375 eth
在短短的几个小时内,产生的债务是~5.5ETH,价值~24000美元。哎哟。
由于我们知道这个头寸的结局,我们知道它在某些时候是可以清算的,所以让我们检查涉及这个用户地址的liquidationCall
的调用:
select
evt_block_number,
collateralAsset,
debtAsset,
debtToCover,
liquidatedCollateralAmount,
liquidator
from
aave_v2_ethereum.LendingPool_evt_LiquidationCall
where
user = from_hex('0x227cAa7eF6D955A92F483dB2BD01172997A1a623')
order by
evt_block_number desc;
可以随意在 Dune Analytics上运行上述查询。
一旦我们找到第一个清算事件,我们就可以理解为什么用户在借贷后不久就存入了资产:
+------------------+--------------------------------------------+--------------------------------------------+-------------------------+----------------------------+--------------------------------------------+
| evt_block_number | collateralAsset | debtAsset | debtToCover | liquidatedCollateralAmount | liquidator |
+------------------+--------------------------------------------+--------------------------------------------+-------------------------+----------------------------+--------------------------------------------+
| 13520838 | 0x6B175474E89094C44DA98B954EEDEAC495271D0F | 0x0F5D2FB29FB7D3CFEE444A200298F468908CC942 | 17919685927295406794873 | 58271102282974799175987 | 0xB2B3D5B6215D4FB23BF8DD642D385C4B44AADB2A |
+------------------+--------------------------------------------+--------------------------------------------+-------------------------+----------------------------+--------------------------------------------+
在这里我们可以看到,第一次清算发生在区块13520838
。这次清算是在用户还没有存入资金之前(在存款交易之前约7分钟)。
然后,在区块13520838
-13522070
之间发生了一连串的小清算,价值不菲:
select
count( * ) as num_liquidations
from
aave_v2_ethereum.LendingPool_evt_LiquidationCall
where
user = from_hex('0x227cAa7eF6D955A92F483dB2BD01172997A1a623')
and evt_block_number <= 13522070 and evt_block_number >= 13520838
+ - - - - - - - - - +
| num_liquidations |
+ - - - - - - - - - +
| 87 |
+ - - - - - - - - - +
让我们检查一下在这些区块之间被清算人从用户那里扣押的所有抵押资产类型:
select
SUM(liquidatedCollateralAmount) as amountSeized,
collateralAsset
from
aave_v2_ethereum.LendingPool_evt_LiquidationCall
where
user = from_hex('0x227cAa7eF6D955A92F483dB2BD01172997A1a623')
and evt_block_number <= 13522070 and evt_block_number >= 13520838
group by collateralAsset
我们可以看到只有2种资产,DAI(稳定币)和ETH。
+--------------------------+--------------------------------------------+
| amountSeized | collateralAsset |
+--------------------------+--------------------------------------------+
| 387663228503220484547359 | 0x6B175474E89094C44DA98B954EEDEAC495271D0F |
+--------------------------+--------------------------------------------+
| 499940913071713798854 | 0xC02AAA39B223FE8D0A0E5C4F27EAD9083C756CC2 |
+--------------------------+--------------------------------------------+
以及它们的金额:
有人可能会问,为什么清算会发生是一小块一小块进行的?
好吧,当像这样巨大的头寸被一次性清算时,市场会将如此大规模的抵押品清算理解为这些资产类型的卖出信号。记住:根据协议的清算人奖励政策,清算中获得的资产是以折扣价购买的。
随着抛售压力的增加,一次大规模的清算可能会产生一个清算的雪球。其他市场参与者可能也会出售他们的资产,导致资产价格进一步 "抛售",这反过来又会导致协议中其他头寸的更多清算。
因此,协议通常限制一次清算所能处理的资产量。AAVE的这个限制的版本,作为一个变量,可以看到下面:
pragma solidity ^ 0.8 .13;
// from ...
uint256 internal constant LIQUIDATION_CLOSE_FACTOR_PERCENT = 5000;
function liquidationCall(
address collateralAsset,
address debtAsset,
address user,
uint256 debtToCover,
bool receiveAToken
) external override returns(uint256, string memory) {
// ...
vars.maxLiquidatableDebt =
vars.userStableDebt.add(vars.userVariableDebt).percentMul(
LIQUIDATION_CLOSE_FACTOR_PERCENT
);
// ...
我们可以看到,限制比例是50%,这意味着在一次清算中只允许偿还头寸的一半债务。
清算人也有动力将清算分成小块。如果在清算时,市场上没有足够的流动性,无法将抵押资产全额提供给清算人。通过将清算分成小块,清算人有更大的机会获得流动资产,并从清算中获得利润。
此外,如果市场上的债务资产没有足够的流动性,那么清算人甚至在一开始就获得该资产以偿还抵押不足的用户的债务可能会成本昂贵。
最后,想象一下试图清算大量的某种你不拥有这么多数量代币。如果你去DEX并试图用一些WETH或任何其他资产来交换这个代币,你也可能会遇到很高的Gas费用,使你的清算无利可图。
回到我们的例子,为了检查清算链后的头寸参数,需要解析从getUserAccountData
返回给我们的数据:
from io import BytesIO
from binascii import unhexlify
from dataclasses import dataclass
@dataclass(frozen=True)
class UserAccountData:
totalCollateralETH: int
totalDebtETH: int
availableBorrowsETH: int
currentLiquidationThreshold: int
ltv: int
healthFactor: int
def parse_user_account_data(uacd: str) -> UserAccountData:
uacd_bytes = unhexlify(uacd[2:])
assert len(uacd_bytes) == 192
uacd_bytes = BytesIO(uacd_bytes)
total_collateral_eth = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
total_debt_eth = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
available_borrows_eth = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
current_liquidation_threshold = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
ltv = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
health_factor = int.from_bytes(bytes=uacd_bytes.read(32), byteorder="big", signed=False)
return UserAccountData(
totalCollateralETH=total_collateral_eth,
totalDebtETH=total_debt_eth,
availableBorrowsETH=available_borrows_eth,
currentLiquidationThreshold=current_liquidation_threshold,
ltv=ltv,
healthFactor=health_factor,
)
然后我们用cast
来查询chain的情况:
➜ ~ cast call -b 13522070 0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9 "getUserAccountData(address)" 0x227cAa7eF6D955A92F483dB2BD01172997A1a623
0x000000000000000000000000000000000000000000000000085b5b5e846685f4000000000000000000000000000000000000000000000002743544e203a3e4ae00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f710000000000000000000000000000000000000000000000000000000000001d9500000000000000000000000000000000000000000000000000260a45667b706b
最后解析输出:
parse_user_account_data('0x000000000000000000000000000000000000000000000000085b5b5e846685f4000000000000000000000000000000000000000000000002743544e203a3e4ae00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f710000000000000000000000000000000000000000
000000000000000000001d9500000000000000000000000000000000000000000000000000260a45667b706b')
UserAccountData(totalCollateralETH=602175436690458100, totalDebtETH=45267162967098778798, availableBorrowsETH=0, currentLiquidationThreshold=8049, ltv=7573, healthFactor=10707342303391851)
在这里我们看到了清算对头寸的影响:几乎没有任何抵押品了~准确地说,是0.6个ETH。但是债务呢?45.26716296709878 ETH !
那么在这个区块的高度,MANA的价格是多少呢?
➜~cast call - b 13522070 0xA50ba011c48153De246E5192C8f9258A2ba79Ca9 "getAssetPrice(address)"
0x0F5D2fB29fb7d3CFeE444a200298f468908cC942
0x00000000000000000000000000000000000000000000000000031015cc1da8f2
0.000862110734985458 eth!
如果你还记得,用户在几个小时前刚刚以0.00032838 ETH的价格借入MANA。这相当于在一只股票上开了一个空头头寸,而这只股票的价格是火箭般的2.65倍--Oof 😮💨!
清算者无法在MANA的价格抛售得太厉害之前及时清算全部头寸,使这个过程无利可图,我们就剩下一个无力偿还的头寸。
现在我们可以体会到一个有效的流动性阈值的重要性,在避免协议中的坏账。
虽然我们不能明确地说,可以用一个方程式来定义头寸的流动性阈值,但我们肯定可以看到协议之间的相似之处:
所有协议都将其阈值定义为抵押品与债务的某个函数(无论是比率还是差值)。
所有协议都留有一定的治理空间,以决定每个抵押品风险参数的价值,以应对市场条件的变化,因为一些资产比其他资产更不稳定。
所有协议都使用预言机对其抵押品和债务价格进行计价,并使用广泛接受的货币(例如,ETH,USD,DAI)。
我们已经看到,Maker和AAVE选择使用相同的方程式来表示头寸的安全性:
作者:Tal 研究员 @ smlXL, 感谢 Sam Ragsdale 和为本帖提供建议和反馈的smlXL团队成员。
感谢 Chaintool 对本翻译的支持, Chaintool 是一个为区块链开发者准备的开源工具箱
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!