探索Liquity V2:去中心化借贷
这篇文章详细探讨了 Liquity V2 的去中心化借贷机制,介绍了多种新特性,包括用户设定利率、多抵押品系统以及流动性池的奖励分配等。文章涵盖了 Liquity V2 的架构、功能与安全性视角,对于想要了解去中心化借贷的用户来说,提供了深刻的见解和理论基础。

我们不仅是代码、经济审计和区块链工程的专家——我们热衷于拆解 Web3 概念,以推动行业的清晰度和采用率。
介绍
Liquity V1 是在代码的唯一治理下构建的不可变协议。自2021年4月推出以来,它便无缝运作,提供0%的利率,同时确保用户资金的安全。随着 Liquity V2 的发布,该平台大胆前行,引入了一个新特性:用户设定的利率,为借款人和稳定币持有者创造了一个更高效、更具吸引力的市场。
Liquity V2 是一个去中心化的抵押债务平台,允许用户锁定资产如 WETH 或 Liquid Staking Tokens (LSTs) 来发行其稳定币Token,称为 BOLD。BOLD 设计为保持1美元的价值,确保系统始终超额抵押,并且 BOLD 始终可以根据协议的抵押品兑换相应数量的抵押品。
该系统使用户能够通过存入 ERC20 代币作为抵押品来打开称为“Troves”的抵押债务头寸。只要抵押比例保持在最低要求之上,便可以根据抵押品借入 BOLD 代币。BOLD 可以在以太坊地址之间自由转移,并且可以被任何人兑换为减去费用后的等值抵押品。
结构
- 核心合约结构如下:
- 有一个
CollateralRegistry,一个BoldToken,以及为每个抵押“分支”部署的一系列核心系统合约。 CollateralRegistry负责将外部 ERC20 抵押代币映射到相应的TroveManager地址。它还处理各个抵押分支间的赎回路由。- 示例: 每个 WETH、rETH 和 wstETH 都有自己专门的抵押分支,包含管理 Troves、清算、处理稳定池存款以及促进特定于该区分支的赎回所需的所有逻辑。
- 有一个

- 顶级合约
CollateralRegistry– 追踪所有 LST 抵押品,并将分支特定的 TroveManagers 映射到这些抵押品。它计算赎回费用并将 BOLD 赎回定向到不同分支的相应 TroveManagers,按其未偿还债务的比例分配。BOLDToken– 这是实现 ERC20 标准并包括 EIP-2612 许可功能的稳定币代币合约。该合约负责铸造、燃烧和转移 BOLD 代币。
- 分支级合约
BorrowerOperations– 包含借款人和经理与其 Troves 互动的核心功能,包括创建 Troves、添加或提取抵押品、发行和偿还 BOLD,以及调整利率。BorrowerOperations的功能调用TroveManager更新 Trove 的状态并与各种池进行互动,将抵押品和 BOLD 在池之间或池与用户之间移动。它还指示ActivePool凭单利息。TroveManager– 负责清算、赎回和计算单个 Troves 的利息。它保持每个 Trove 的状态,包括抵押品、债务和利率。然而,TroveManager本身并不拥有任何价值(抵押品或 BOLD)。它在需要时调用各种池以移动抵押品或 BOLD。TroveNFT– 为 Trove NFTs 实现基本的铸造和燃烧功能,并由TroveManager控制。它还实现了tokenURI功能,为每个 Trove 提供元数据,包括唯一图像。LiquityBase– 包含CollateralRegistry、TroveManager、BorrowerOperations和StabilityPool使用的共享功能。StabilityPool– 管理稳定池的操作,如存入、提款复利存款、清算获取的抵押品和 BOLD收益。它持有所有存款人在某个分支的稳定池 BOLD 存款、收益和清算抵押品。SortedTroves– 一个双向链表,存储以 Trove 拥有者地址为索引的地址,按他们的年利率进行排序。它会根据利率自动插入和重新插入 Troves。该合约还处理批量 Troves 的插入和重新插入,建模为双向链表的切片。ActivePool– 持有一个分支的抵押品余额并跟踪活动 Troves 的总 BOLD 债务。它铸造累计利息,分配给StabilityPool和收益路由器(目前为 DEX LP 激励的MockInterestRouter)。DefaultPool– 持有被清算 Troves 的抵押品余额和 BOLD 债务,待分配给活动 Troves。如果一个活跃 Trove 在DefaultPool中有待分配的抵押品和债务“奖励”,则将在下一个借款人操作、赎回或清算期间应用到该 Trove。CollSurplusPool– 追踪和持有来自清算 Troves 的抵押品盈余。当借款人申请时,它分配借款人累积的盈余。GasPool– 管理 WETH Gas补偿。当打开 Trove 时,WETH 从借款人转移到GasPool,在 Trove 被清算或关闭时支付。MockInterestRouter– 一个占位合约,当前接收铸造利息的 LP 收益分配。稍后将由真实的收益路由器替换,该路由器将收益引导至 DEX LP 激励。
- 外围辅助合约
HintHelpers– 一个辅助合约,提供读取功能以计算准确的提示,这些提示可以提供给借款人操作。MultiTroveGetter– 一个辅助合约,提供读取功能以获取 Trove 数据结构数组,包含每个 Trove 的完整记录状态。
- 预言机价格馈送合约 由于 LST 上不同的价格计算方法,使用不同的
PriceFeed合约来给不同分支的抵押品定价。然而,核心功能在父合约之间共享。更多信息可以在 这里 阅读。
V2 中的新内容
- 多抵押体系: 系统现在作为多抵押框架运行,由一个
CollateralRegistry和多个抵押分支组成。每个分支都可以独立配置自己的 最低抵押比例(MCR)、临界抵押比例(CCR)和 关闭抵押比例(SCR)。每个分支还有自己的TroveManager和StabilityPool,在某个分支里,Troves 仅接受单一类型的抵押品。一个分支中的清算仅对应于其相应的稳定池,清算时的任何收益都以该特定抵押品的形式支付给存款者。清算中的抵押品和债务的重分配也只适用于同一分支内的活动 Troves。CollateralRegistry的构造函数可以接受最多10个TroveManagers及其各自的抵押类型。然而,目前仅接受 WETH 和两个 LST——rETH 和 wstETH,原生 ETH 不被接受。
1constructor(IBoldToken _boldToken, IERC20Metadata[] memory _tokens, ITroveManager[] memory _troveManagers)
- 用户设定利率: 借款人在 打开 Trove 时可以选择自己的年利率,并可以在任何时候 调整 它。简单的、非复利的利息持续累积于其债务上,仅在与 Trove 互动时复利。总的未偿债务会定期 铸成 BOLD。最低利率不是默认选择,因为赎回被优先安排给利率最低的 Troves,如本文后面所述。
1/* 按它们各自选择的利率加权的单个记录的 Trove 债务总和。
2* 在个别 Trove 操作时更新。
3* "S" 在规范中。
4*/
5uint256 public aggWeightedDebtSum;
1function calcPendingAggInterest() public view returns (uint256) {
2 if (shutdownTime != 0) return 0;
3 return Math.ceilDiv(aggWeightedDebtSum * (block.timestamp - lastAggUpdateTime), ONE_YEAR * DECIMAL_PRECISION);
4}
- 利息收益分配: 利息带来的收益分配给稳定池(SP)和流动性提供者(LP)。由 Trove 利息生成的 BOLD 会定期被拆分,其中一部分送往 SP 另一部分通过收益路由器归入 DEX LP 的激励。特定分支的利息收益总是支付给该分支的 SP。分配发生在
_mintAggInterest中的ActivePool
1function _mintAggInterest(IBoldToken _boldToken, uint256 _upfrontFee) internal returns (uint256 mintedAmount) {
2 mintedAmount = calcPendingAggInterest() + _upfrontFee;
3
4 // 将部分 BOLD 利息铸造成 SP,部分铸造给流动性提供者的路由器。
5 if (mintedAmount > 0) {
6 uint256 spYield = SP_YIELD_SPLIT * mintedAmount / DECIMAL_PRECISION;
7 uint256 remainderToLPs = mintedAmount - spYield;
8
9 _boldToken.mint(address(interestRouter), remainderToLPs);
10 _boldToken.mint(address(stabilityPool), spYield);
11
12 stabilityPool.triggerBoldRewards(spYield);
13 }
14
15 lastAggUpdateTime = block.timestamp;
16}
这个函数在 mintAggInterestAndAccountForTroveChange 中被调用,该函数由所有改变状态的用户操作触发,包括借款人操作、清算、赎回和稳定池的存款/提款。如果用户的操作改变了 Trove 的债务,则通过待积分的总利息和净 Trove 债务变化更新聚合记录债务。
1function mintAggInterestAndAccountForTroveChange(TroveChange calldata _troveChange, address _batchAddress)
2 external
3{
4 _requireCallerIsBOorTroveM();
5
6 // 批量管理费用
7 if (_batchAddress != address(0)) {
8 _mintBatchManagementFeeAndAccountForChange(boldToken, _troveChange, _batchAddress);
9 }
10
11 // 这里做两步计算以避免从减少中溢出
12 uint256 newAggRecordedDebt = aggRecordedDebt; // 1 SLOAD
13 newAggRecordedDebt += _mintAggInterest(boldToken, _troveChange.upfrontFee); // 添加已铸造的聚合利息 + 前期费用
14 newAggRecordedDebt += _troveChange.appliedRedistBoldDebtGain;
15 newAggRecordedDebt += _troveChange.debtIncrease;
16 newAggRecordedDebt -= _troveChange.debtDecrease;
17 aggRecordedDebt = newAggRecordedDebt; // 1 SSTORE
18
19 uint256 newAggWeightedDebtSum = aggWeightedDebtSum; // 1 SLOAD
20 newAggWeightedDebtSum += _troveChange.newWeightedRecordedDebt;
21 newAggWeightedDebtSum -= _troveChange.oldWeightedRecordedDebt;
22 aggWeightedDebtSum = newAggWeightedDebtSum; // 1 SSTORE
23}
- 赎回路由: BOLD 赎回 由 CollateralRegistry 管理,并根据它的“非法担保”水平在分支之间分配. 向每个分支转移的赎回量与其未担保程度成正比。赎回的主要目标是恢复 BOLD 锚定,而次要目标是比其他担保更好的分支减少更多未担保程度。未担保性是指蹭池总的 BOLD 债务与其稳定池中储备的 BOLD 之间的差异。
1function getUnbackedPortionPriceAndRedeemability() external returns (uint256, uint256, bool) {
2 uint256 totalDebt = getEntireSystemDebt();
3 uint256 spSize = stabilityPool.getTotalBoldDeposits();
4 uint256 unbackedPortion = totalDebt > spSize ? totalDebt - spSize : 0;
5
6 (uint256 price,) = priceFeed.fetchPrice();
7 // 如果 TCR 超过关闭阈值且分支未关闭,则可赎回
8 bool redeemable = _getTCR(price) >= SCR && shutdownTime == 0;
9
10 return (unbackedPortion, price, redeemable);
11}
1function redeemCollateral(uint256 _boldAmount, uint256 _maxIterationsPerCollateral, uint256 _maxFeePercentage)
2 external
3{
4 ...
5
6 // 收集并积累未担保部分
7 for (uint256 index = 0; index < totals.numCollaterals; index++) {
8 ITroveManager troveManager = getTroveManager(index);
9 (uint256 unbackedPortion, uint256 price, bool redeemable) =
10 troveManager.getUnbackedPortionPriceAndRedeemability();
11 prices[index] = price;
12 if (redeemable) {
13 totals.unbacked += unbackedPortion;
14 unbackedPortions[index] = unbackedPortion;
15 }
16 }
17
18 ...
19
20 // 计算每个抵押的赎回金额,并根据相应的 TroveManager 进行赎回
21 for (uint256 index = 0; index < totals.numCollaterals; index++) {
22 //uint256 unbackedPortion = unbackedPortions[index];
23 if (unbackedPortions[index] > 0) {
24 uint256 redeemAmount = _boldAmount * unbackedPortions[index] / totals.unbacked;
25 if (redeemAmount > 0) {
26 ITroveManager troveManager = getTroveManager(index);
27 uint256 redeemedAmount = troveManager.redeemCollateral(
28 msg.sender, redeemAmount, prices[index], redemptionRate, _maxIterationsPerCollateral
29 );
30 totals.redeemedAmount += redeemedAmount;
31 }
32 }
33 }
34
35 ...
36}
- 赎回顺序: 赎回被选定在其未担保程度上的分支中执行,而在各个分支内,赎回按照其年利率的顺序进行,优先赎回利率最低的。利率较高的 Troves 在赎回中受到更多保护,因为它们的“债务在前”相较于低利率的 Troves 更多。Troves 的抵押比例在赎回顺序中不被考虑。循环遍历 Troves 的实际逻辑在
TroveManager的redeemCollateral内执行。
1function redeemCollateral(
2 address _redeemer,
3 uint256 _boldamount,
4 uint256 _price,
5 uint256 _redemptionRate,
6 uint256 _maxIterations
7) external override returns (uint256 _redemeedAmount) {
8 ...
9
10 // 从抵押比率最低的 Troves 开始循环直到将 _amount 的 BOLD 兑换为抵押品
11 if (_maxIterations == 0) _maxIterations = type(uint256).max;
12 while (singleRedemption.troveId != 0 && remainingBold > 0 && _maxIterations > 0) {
13 _maxIterations--;
14 // 保存当前 Trove 前面的 uint256
15 uint256 nextUserToCheck = sortedTrovesCached.getPrev(singleRedemption.troveId);
16 // 如果 ICR < 100%,跳过,以确保赎回总是改善当前 Troves 的 CR
17 if (getCurrentICR(singleRedemption.troveId, _price) < _100pct) {
18 singleRedemption.troveId = nextUserToCheck;
19 continue;
20 }
21
22 ...
23 }
24
25 ...
26}
- 不可赎回的 Troves: 赎回不再关闭 Troves,而是保持其开放状态。如果赎回将 Trove 的 BOLD 债务减少到零或低于 MIN_DEBT 阈值,则其会被标记为不可赎回,以防赎回破坏攻击。这些 Troves 可以在借款人将债务增加回高于 MIN_DEBT 的阈值后变得可赎回。
TroveManager中的_redeemCollateralFromTrove函数在redeemCollateral被 调用 时负责将 Trove 标记为不可赎回。
1function _redeemCollateralFromTrove(
2 IDefaultPool _defaultPool,
3 SingleRedemptionValues memory _singleRedemption,
4 uint256 _maxBoldamount,
5 uint256 _price,
6 uint256 _redemptionRate
7) internal {
8 ...
9
10 uint256 newDebt = _applySingleRedemption(_defaultPool, _singleRedemption, isTroveInBatch);
11
12 // 如若过小,则使得 Trove 不可赎回,以防止未来(正常,顺序)赎回的破坏攻击
13 if (newDebt < MIN_DEBT) {
14 Troves[_singleRedemption.troveId].status = Status.unredeemable;
15 if (isTroveInBatch) {
16 sortedTroves.removeFromBatch(_singleRedemption.troveId);
17 } else {
18 sortedTroves.remove(_singleRedemption.troveId);
19 }
20 }
21}
- 代表 NFTs 的 Troves: Troves 可自由转让,可以由多个以太坊地址所有,每个 Trove 由一个对应的 NFT 表示。一个地址可以通过拥有这些 NFTs 拥有多个 Troves。NFT 在
TroveManager中 打开 Trove 时被 铸造,并在 关闭 Trove 时被 燃烧。 - 单独委托: Trove 所有者可以委托一个单独的经理来管理他们的 Trove,允许该经理设置利率并调整债务和抵押品。委托通过
BorrowerOperations中的setInterestIndividualDelegate函数进行。以下函数用于检查特定的委托是否有资格更改利率:
1function _requireSenderIsOwnerOrInterestManager(uint256 _troveId, address _owner) internal view {
2 if (msg.sender != _owner && msg.sender != interestIndividualDelegateOf[_troveId].account) {
3 revert NotOwnerNorInterestManager();
4 }
5}
- 批量委托: Trove 所有者可以委托一个批量经理来管理他们的利率。该批量经理能够在注册时在预定范围内调整所有 Troves 的利率。批量利率调整可以高效地更新批量中所有 Troves 的利率,降低Gas费用。
- 批量结构: 批量被建模为 SortedTroves 列表中的切片,利用新的
Batch数据结构,具有首部和尾部属性。当批量经理更新利率时,整个批量被重新插入到基于新利率的适当位置。为了降低Gas成本,批量的处理方式类似于“共享 Troves”,系统跟踪批量的总债务和利率。利息和管理费用随时间累积,而每个 Trove 的重分配收益则单独追踪。 - 批量管理费用: 批量按照年费率累积管理费用,其计算方式与利息类似。当以下情况发生时,会更新批量的记录债务:
- 借款人调整其 Trove 的债务。
- 批量经理更改利率。
- 应用批量内某个 Trove 的待处理债务。
- 提前调整费用: 如果批量经理在冷却期结束之前改变利率,则需支付提前调整费用,费用结构与单独的 Troves 类似。批量中的借款人依赖经理的能力来避免过多的费用。能够胜任的批量经理被期望建立声誉并吸引更多借款人,而低劣的经理可能会看到其批量逐渐空置。
- 批量不变性: 批量的 Troves 与单独的 Troves 功能相同。如果两个 Troves——一个在批量中,一个独立——在同一状态下,经过执行相同的操作后(如调整抵押、债务或接收重分配收益),它们将保持相同。
- 批量结构: 批量被建模为 SortedTroves 列表中的切片,利用新的
1struct Batch {
2 uint256 debt;
3 uint256 coll;
4 uint64 arrayIndex;
5 uint64 lastDebtUpdateTime;
6 uint64 lastInterestRateAdjTime;
7 uint256 annualInterestRate;
8 uint256 annualManagementFee;
9 uint256 totalDebtShares;
10}
当新批量的利率被更新时,将自动影响该批中的所有 Troves:
1function onSetBatchManagerAnnualInterestRate(
2 address _batchAddress,
3 uint256 _newColl,
4 uint256 _newDebt,
5 uint256 _newAnnualInterestRate
6) external {
7 batches[_batchAddress].coll = _newColl;
8 batches[_batchAddress].debt = _newDebt;
9 batches[_batchAddress].annualInterestRate = _newAnnualInterestRate;
10 batches[_batchAddress].lastDebtUpdateTime = uint64(block.timestamp);
11 batches[_batchAddress].lastInterestRateAdjTime = uint64(block.timestamp);
12}
_updateBatchShares(https://github.com/liquity/bold/blob/a34960222df5061fa7c0213df5d20626adf3ecc4/contracts/src/TroveManager.sol#L1720)函数在管理单个 Troves 及其所属批量之间的关系中起着关键作用。此函数确保与 Trove 相关的债务和抵押品在相应的批量中准确反映。
onOpenTroveAndJoinBatch()、onAdjustTroveInsideBatch()、onRegisterBatchManager()、onLowerBatchManagerAnnualFee()、onSetBatchManagerAnnualInterestRate()、onSetInterestBatchManager() 和 onRemoveFromBatch() 负责在它们属于一个批量时更新它们,并更新相关的批量经理变量。
在进行赎回时,对于属于批量的 Troves 进行特定处理:
1if (isTroveInBatch) {
2 _getLatestBatchData(_singleRedemption.batchAddress, _singleRedemption.batch);
3 // 我们知道 boldLot <= trove 整个债务,因此这个减法是安全的
4 uint256 newAmountForWeightedDebt = _singleRedemption.batch.entireDebtWithoutRedistribution
5 + _singleRedemption.trove.redistBoldDebtGain - _singleRedemption.boldLot;
6 _singleRedemption.oldWeightedRecordedDebt = _singleRedemption.batch.weightedRecordedDebt;
7 _singleRedemption.newWeightedRecordedDebt =
8 newAmountForWeightedDebt * _singleRedemption.batch.annualInterestRate;
9}
10...
清算机制也考虑到一个 Trove 是否是批量中的一部分:
1if (isTroveInBatch) {
2 singleLiquidation.oldWeightedRecordedDebt =
3 batch.weightedRecordedDebt + (trove.entireDebt - trove.redistBoldDebtGain) * batch.annualInterestRate;
4 singleLiquidation.newWeightedRecordedDebt = batch.entireDebtWithoutRedistribution * batch.annualInterestRate;
5 // 生成批量管理费用
6 troveChange.batchAccruedManagementFee = batch.accruedManagementFee;
7 activePool.mintBatchManagementFeeAndAccountForChange(troveChange, batchAddress);
8}
当然,这并不是批量考虑的唯一情况,但它们提供了一个很好的例子。
- 抵押分支关闭: 在极端情况下,如抵押市场价格大幅崩盘或预言机故障,抵押分支将被关闭。这将导致所有借款人操作(关闭 Trove 除外)被冻结,停止利息的累积,并启用紧急赎回,没有赎回费用,且为赎回者提供少量抵押品奖励。目标是快速减少受影响分支的债务。分支可以被关闭,当总抵押比例(TCR)低于或等于关闭抵押比例(SCR)时。在这种情况下,任何人都可以调用
BorrowerOperations合约中的shutdown函数,该函数会关闭所有其他分支合约,如TroveManager和ActivePool。关闭的另一个场景是在预言机失败时(回退或返回0)。在这种情况下,继承自MainnetPriceFeedBase的合约通过其_disableFeedAndShutDown函数调用BorrowerOperations中的shutdownFromOracleFailure函数以触发关闭。- 关闭时
- 所有待决的汇总利息和批量管理费用都会被应用和铸造。
- 之后,不再铸造或累积任何进一步的聚合利息或批量管理费用。
- individuele Troves 停止累积利息,累积的利息只计算到关闭时间戳为止。
- 批量停止累积利息和管理费用,所有计算的值仅反映关闭时间戳之前的值。
- 关闭逻辑 在关闭期间,不允许以下操作:
- 打开新 Trove。
- 调整 Trove 的债务、抵押品或利率。
- 应用 Trove 的利息。
- 调整批量的利率。
- 应用批量的利息和管理费。
- 正常赎回。
- 关闭期间允许的操作
- 关闭一个 Trove。
- 清算 Troves。
- 向稳定池存款或提款。
- 紧急赎回(如下所述)。
- 紧急赎回 在关闭期间,赎回逻辑被修改,以激励快速减少分支的债务,即使 BOLD 的交易价格为 1 美元。紧急赎回:
- 直接通过关闭分支的
TroveManager执行,并仅影响该分支,不经过其他分支。 - 不收取赎回费用。
- 为赎回者提供1%的抵押品奖励,即每赎回1 BOLD,赎回者获得价值1.01美元的抵押品。
- 不按利率顺序赎回 Troves。而是赎回者传递一组要赎回的 Troves 列表。
- 即使 Trove 剩余非常小或为零债务,也不会创建不可赎回的 Troves,确保未来的紧急赎回不受很小 Troves 的影响。
- 直接通过关闭分支的
- 关闭时
BorrowerOperations
1
2// 系统关闭抵押比例。在给定的抵押品的系统总抵押比例(TCR)低于 SCR 时,
3// 协议触发借款市场的关闭,永久禁用所有借款操作,仅允许关闭 Troves。
4uint256 public immutable SCR;
5
6...
7
8function shutdown() external {
9 if (hasBeenShutDown) revert IsShutDown();
10
11 uint256 totalColl = getEntireSystemColl();
12 uint256 totalDebt = getEntireSystemDebt();
13 (uint256 price,) = priceFeed.fetchPrice();
14
15 uint256 TCR = LiquityMath._computeCR(totalColl, totalDebt, price);
16 if (TCR >= SCR) revert TCRNotBelowSCR();
17
18 _applyShutdown();
19
20 emit ShutDown(TCR);
21}
22
23...
24
25// 技术上并不是“借款人操作”,但似乎由于当前的关闭逻辑放在这里比较合适。
26function shutdownFromOracleFailure(address _failedOracleAddr) external {
27 _requireCallerIsPriceFeed();
28
29 // 为了避免反转即使系统已关闭,也不让外部函数调用获取价格时反转
30 if (hasBeenShutDown) return;
31
32 _applyShutdown();
33
34 emit ShutDownFromOracleFailure(_failedOracleAddr);
35}
36
37...
38
39function _applyShutdown() internal {
40 activePool.mintAggInterest();
41 hasBeenShutDown = true;
42 troveManager.shutdown();
43}
WETHPriceFeed
1function _fetchPrice() internal override returns (uint256, bool) {
2 (uint256 ethUsdPrice, bool ethUsdOracleDown) = _getOracleAnswer(ethUsdOracle);
3
4 // 如果本次交易中的 Chainlink 响应无效,则返回上次好的 ETH-USD 价格
5 if (ethUsdOracleDown) return (_disableFeedAndShutDown(address(ethUsdOracle.aggregator)), true);
6
7 lastGoodPrice = ethUsdPrice;
8
9 return (ethUsdPrice, false);
10}
11
12...
13
14function _disableFeedAndShutDown(address _failedOracleAddr) internal returns (uint256) {
15 // 关闭该分支
16 borrowerOperations.shutdownFromOracleFailure(_failedOracleAddr);
17
18 priceFeedDisabled = true;
19 return lastGoodPrice;
20}
TroveManager
1function shutdown() external {
2 _requireCallerIsBorrowerOperations();
3 shutdownTime = block.timestamp;
4 activePool.setShutdownFlag();
5}
6
7...
8
9function _requireIsShutDown() internal view {
10 if (shutdownTime == 0) {
11 revert NotShutDown();
12 }
13}
14
15...
16
17function _requireIsNotShutDown() internal view {
18 if (hasBeenShutDown) {
19 revert IsShutDown();
20 }
21}
22
23...
24
25function urgentRedemption(uint256 _boldAmount, uint256[] calldata _troveIds, uint256 _minCollateral) external {
26 _requireIsShutDown();
27 ...
28}
ActivePool
1function setShutdownFlag() external {
2 _requireCallerIsTroveManager();
3 shutdownTime = block.timestamp;
4}
5
6...
7
8function hasBeenShutDown() external view returns (bool) {
9 return shutdownTime != 0;
10}
11
12...
13
14function calcPendingAggInterest() public view returns (uint256) {
15 if (shutdownTime != 0) return 0;
16 ...
17}
```- **恢复模式的移除**:之前的恢复模式逻辑已经被移除。只有当个人抵押品比率(ICR)低于最低抵押品比率(MCR)时,才会对借款进行清算。然而,当总抵押品比率(TCR)低于特定分支的临界抵押品比率(CCR)时,仍然适用借贷限制。这是因为某些操作可能会降低 ICR 进而使 TCR 低于 CCR。当 TCR 低于 CCR 时,借贷受到限制,并且在没有等值或更大价值的债务偿还的情况下,设定利率或提取抵押品等操作也受到限制。在 `BorrowerOperations` 中的 `_requireNewTCRisAboveCCR` 函数确保在借款操作期间 TCR 始终健康:
### `BorrowerOperations`
```solidity
1function _requireValidAdjustmentInCurrentMode(
2 TroveChange memory _troveChange,
3 LocalVariables_adjustTrove memory _vars
4) internal view {
5 /*
6 * 在临界阈值以下,不允许:
7 *
8 * - 借款
9 * - 除非伴随至少相同金额的债务偿还,否则不允许提取抵押品
10 *
11 * 在正常模式下,确保:
12 *
13 * - 调整不会使 TCR 低于 CCR
14 *
15 * 在这两种情况下:
16 * - 新的 ICR 应高于 MCR
17 */
18 _requireICRisAboveMCR(_vars.newICR);
19
20 if (_vars.isBelowCriticalThreshold) {
21 _requireNoBorrowing(_troveChange.debtIncrease);
22 _requireDebtRepaymentGeCollWithdrawal(_troveChange, _vars.price);
23 } else {
24 // 如果是正常模式
25 uint256 newTCR = _getNewTCRFromTroveChange(_troveChange, _vars.price);
26 _requireNewTCRisAboveCCR(newTCR);
27 }
28}
29
30...
31
32function _requireNewTCRisAboveCCR(uint256 _newTCR) internal view {
33 if (_newTCR < CCR) {
34 revert TCRBelowCCR();
35 }
36}
TroveManager
1function _batchLiquidateTroves(
2 IDefaultPool _defaultPool,
3 uint256 _price,
4 uint256 _boldInStabPool,
5 uint256[] memory _troveArray,
6 LiquidationValues memory totals,
7 TroveChange memory troveChange
8) internal {
9 ....
10
11 uint256 ICR = getCurrentICR(troveId, _price);
12
13 if (ICR < MCR) {
14 ....
15 }
16}
- 清算罚款:被清算的借款者不再总是会失去其所有的抵押品。根据特定的抵押品分支和清算类型,他们可能能够收回一小部分残余抵押品。
TroveManager中的_liquidate将剩余的抵押品转移到CollSurplusPool:
1function _liquidate(
2 IDefaultPool _defaultPool,
3 uint256 _troveId,
4 uint256 _boldInStabPool,
5 uint256 _price,
6 LatestTroveData memory trove,
7 LiquidationValues memory singleLiquidation
8) internal {
9 ...
10
11 // 清算罚金与清算阈值之间的差异
12 if (singleLiquidation.collSurplus > 0) {
13 collSurplusPool.accountSurplus(owner, singleLiquidation.collSurplus);
14 }
15
16 ...
17}
借款者可以在 CollSurplusPool 中调用 claimColl 来认领其剩余抵押品:
1function accountSurplus(address _account, uint256 _amount) external override {
2 _requireCallerIsTroveManager();
3
4 uint256 newAmount = balances[_account] + _amount;
5 ...
6}
1function claimColl(address _account) external override {
2 _requireCallerIsBorrowerOperations();
3 uint256 claimableColl = balances[_account];
4 require(claimableColl > 0, "CollSurplusPool: 没有可认领的抵押品");
5
6 ...
7
8 collToken.safeTransfer(_account, claimableColl);
9}
- Gas补偿:清算人现在通过抵押品和 WETH 的组合获得Gas费用补偿。清算储备始终以 WETH 为单位,无论抵押品类型如何,且包括以抵押品形式的额外补偿。然而,这种抵押品补偿是有限制的,以防止过度支付。负责计算补偿金额的函数是
_getCollGasCompensation:
1// 返回从一个抵押品的抵押中提取并作为Gas补偿发送的 Coll 的数量。
2function _getCollGasCompensation(uint256 _entireColl) internal pure returns (uint256) {
3 return LiquityMath._min(_entireColl / COLL_GAS_COMPENSATION_DIVISOR, COLL_GAS_COMPENSATION_CAP);
4}
1function _liquidate(
2 IDefaultPool _defaultPool,
3 uint256 _troveId,
4 uint256 _boldInStabPool,
5 uint256 _price,
6 LatestTroveData memory trove,
7 LiquidationValues memory singleLiquidation
8) internal {
9 ...
10
11 singleLiquidation.collGasCompensation = _getCollGasCompensation(trove.entireColl);
12 uint256 collToLiquidate = trove.entireColl - singleLiquidation.collGasCompensation;
13
14 ...
15}
_sendGasCompensation 函数用于从 ActivePool 或 GasPool 合约中提取Gas费用,这个合约的唯一目的就是为清算人预留Gas储备。
1function _sendGasCompensation(IActivePool _activePool, address _liquidator, uint256 _eth, uint256 _coll) internal {
2 if (_eth > 0) {
3 WETH.transferFrom(gasPoolAddress, _liquidator, _eth);
4 }
5
6 if (_coll > 0) {
7 _activePool.sendColl(_liquidator, _coll);
8 }
9}
- SP 奖励认领的更多灵活性:稳定池(SP)存款人现在可以选择认领或保存他们在清算中获取的 LST 收益。此外,他们可以选择认领他们的 BOLD 收益,或将其自动添加到他们的现有存款中。
1function _getYieldToKeepOrSend(uint256 _currentYieldGain, bool _doClaim) internal pure returns (uint256, uint256) {
2 uint256 yieldToKeep;
3 uint256 yieldToSend;
4
5 if (_doClaim) {
6 yieldToKeep = 0;
7 yieldToSend = _currentYieldGain;
8 } else {
9 yieldToKeep = _currentYieldGain;
10 yieldToSend = 0;
11 }
12
13 return (yieldToKeep, yieldToSend);
14}
1function getDepositorYieldGain(address _depositor) public view override returns (uint256) {
2 uint256 initialDeposit = deposits[_depositor].initialValue;
3
4 if (initialDeposit == 0) return 0;
5
6 Snapshots memory snapshots = depositSnapshots[_depositor];
7
8 uint256 yieldGain = _getYieldGainFromSnapshots(initialDeposit, snapshots);
9 return yieldGain;
10}
1function withdrawFromSP(uint256 _amount, bool _doClaim) external override {
2 ...
3
4 uint256 currentCollGain = getDepositorCollGain(msg.sender);
5 uint256 currentYieldGain = getDepositorYieldGain(msg.sender);
6 uint256 compoundedBoldDeposit = getCompoundedBoldDeposit(msg.sender);
7 uint256 boldToWithdraw = LiquityMath._min(_amount, compoundedBoldDeposit);
8 (uint256 keptYieldGain, uint256 yieldGainToSend) = _getYieldToKeepOrSend(currentYieldGain, _doClaim);
9 ...
10
11 _updateDepositAndSnapshots(msg.sender, newDeposit, newStashedColl);
12 _decreaseYieldGainsOwed(currentYieldGain);
13 _updateTotalBoldDeposits(keptYieldGain, boldToWithdraw);
14 _sendBoldtoDepositor(msg.sender, boldToWithdraw + yieldGainToSend);
15 _sendCollGainToDepositor(collToSend);
16}

安全视角
预言机抢跑攻击
使用推送预言机进行抵押品定价,这可能会受到抢跑攻击的威胁。在这种攻击中,攻击者可以在内存池中观察到即将到来的价格上涨,执行赎回交易,然后在更新处理之前出售已赎回的抵押品,待价格上涨得到验证后又以更高的价格出售。这使得攻击者可以提取超过传统套利收益的利润。
在 Liquity v1 中,这个问题通过 0.5% 的最低赎回费用得到了缓解,该费用匹配了 Chainlink 的 ETH-USD 预言机的 0.5% 更新阈值。在 v2 中,一些 LST-ETH 预言机的更新阈值较大(例如 Chainlink 的 RETH-ETH 为 2%),但由于这些数据源通常基于心跳而不是价格偏离而更新,因此预期抢跑问题将会减少。
赎回路由操控
赎回路由逻辑通过相同的比例减少每个分支的“外部”债务,分支的外部债务计算为:
outside_debt_i = bold_debt_i - bold_in_SP_i
这使得赎回者通过将资金存入其不希望赎回的分支的稳定池(SP)来暂时操控特定分支的外部债务。这种策略将赎回引向赎回者希望的分支,使他们能够针对潜在在外部市场中滑点更低的特定 Liquid Staking Tokens(LST)。
攻击者可以通过向不想要的分支的 SP 存入 BOLD,并在同一笔交易中从选择的分支赎回,来实现这一目标,可能资金通过闪电贷提供。
解决方案:目前没有解决措施,因为:
- 赎回套利竞争激烈,闪电贷费用减少了攻击者的利润。
- 操控并不从系统中提取直接价值。
- 赎回路由是一种软措施,目的是将系统推向更好的健康状态,但并不是系统稳定的关键,稳定性主要依赖于抵押市场的健康。
路径依赖的赎回费用
Liquity v2 中的赎回费用是路径依赖的。这意味着,在一笔交易中赎回大量的 BOLD 会产生比在多笔小额交易中赎回同样数量的 BOLD 更高的费用(假设交易之间系统状态没有发生变化)。因此,赎回者可能会受到激励,将赎回拆分为较小的部分,以最小化总费用。
示例:
说明此费用结构的示例可以在此 电子表格 找到。
解决方案:
认为没有必要修复以下原因:
- 竞争套利:赎回套利是竞争性的,利润空间很小。将赎回拆分为较小交易会增加总Gas成本,从而减少整体套利利润。
- 已证实的稳定性:在 Liquity v1 中使用了相同的费用公式,它有效地维持了 BOLD 的Hook。
- 非关键优化:赎回费用公式的参数是“最佳估计”,没有理由认为即使预定的费用结构是完美的最优。
预言机故障和分支关闭的后果
当预言机故障触发分支关闭时,该分支的 PriceFeed 的 fetchPrice 函数默认为该分支 LST 的记录 lastGoodPrice。在关闭后,此价格将用于紧急赎回,即使真实市场价格可能不同,这可能导致潜在的扭曲:
解决方案状态:目前没有解决方案,原因如下:
- 过度赎回(lastGoodPrice < 市场价格):虽然紧急赎回返回了过多的抵押品,但仍有助于清除分支的债务。
- 欠赎回(lastGoodPrice > 市场价格):在这种情况下,一些 BOLD 债务保持未清,导致无担保债务。这是多重抵押系统中已知的风险,依赖于集成 LST 资产的经济健康。解决坏债务的方案仍待实施(详见 分支关闭和坏债务 部分)。
- 预言机故障的可能性:预言机故障更可能是由于禁用了 Chainlink 数据源,而不是技术问题或黑客攻击。禁用的 LST 预言机很可能表明该资产流动性或交易量低,这意味着它仅占 Liquity v2 抵押品总额的一小部分。
关闭触发之前的陈旧预言机价格
Liquity v2 会监控市场预言机的答案是否过时,如果它们超过预设阈值,将触发相应分支的关闭。然而,在最后一次预言机更新与分支关闭之间,系统持续使用最近的(可能过时的)预言机价格。这可能导致价格差异,从而引发以下扭曲:
- 由于过时的抵押品价格造成的有利或不利赎回。
- 抵押不充分的借款,用户以不足的抵押品借出 BOLD。
- 健康 Troves 的清算,如果抵押品价格被错误低估。
解决方案:为最小化这些风险,必须精心选择过时阈值参数。更易波动的价格反馈如 ETH-USD 和 STETH-USD 应该有比 LST-ETH 反馈更低的过时阈值,因为 USD 反馈更容易出现显著的价格偏差。所有过时阈值应大于推送预言机的更新心跳时间,以避免过早关闭。
结论
总体而言,Liquity V2 为 DeFi 领域带来了新的创新,使其已经成功的前身在借贷协议中更具竞争力。在原有坚实的基础上,Liquity v2 凭借其新特性和改进,有望取得成功,为用户提供的好处无疑会引起广泛共鸣。我们期待 Liquity 团队的平稳和成功发布!
请联系我们的 Three Sigma,让我们经验丰富的专业团队自信地引导你穿越 Web3 领域。凭借我们在智能合约安全、经济模型和区块链工程方面的专业知识,我们将帮助你确保项目的未来。
今天就 联系我们,将你的 Web3 理想变为现实!
参考文献
https://github.com/liquity/dev/blob/main/README.md
https://www.liquity.org/blog/liquity-v2-enhancing-the-borrowing-experience
https://www.liquity.org/blog/liquity-v2-why-user-set-interest-rates
- 原文链接: threesigma.xyz/blog/liqu...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~