Liquity V2:去中心化借贷详解

本文介绍了 Liquity V2,这是一个去中心化的抵押债务平台,允许用户锁定 WETH 或 LST 等资产以发行稳定币 BOLD。V2 版本引入了用户设定的利率、多抵押品系统、改进的赎回路由和清算机制等新功能,旨在提高 DeFi 借贷市场的效率和竞争力。文章还讨论了 Liquity V2 中的安全考虑因素,包括防止预言机抢跑交易、操纵赎回路由以及管理预言机故障等。

Liquity V2 去中心化借贷指南

探索 Liquity V2 如何通过新功能支持去中心化借贷。了解 DeFi 中加密货币抵押债务和稳定币 BOLD。

Liquity V2 去中心化借贷指南 | Three Sigma

Liquity V2 简介:去中心化借贷的新纪元

随着 Liquity V2 的发布,Liquity 协议迈出了大胆的一步,引入了一个新功能:用户设定的利率,为借款人和稳定币持有者创造了一个更高效、更具吸引力的市场。Liquity V2 是一个去中心化的抵押债务平台,用于去中心化借贷,允许用户锁定 WETH 或流动性质押代币 (LST) 等资产,以发行其稳定币代币 BOLD。BOLD 旨在通过确保系统始终过度抵押,并且 BOLD 始终可以兑换为相应数量的协议抵押品,从而保持 1 美元的价值。请参阅我们的经济审计以了解更多信息。该系统使用户能够通过存入 ERC20 代币 作为抵押品来开设抵押债务头寸,称为 Trove。只要抵押率保持在最低要求之上,就可以根据抵押品借入 BOLD 代币。BOLD 可以在以太坊地址之间自由转移,并且可以被任何人兑换为价值一美元的抵押品,减去费用。对于协议团队,这需要进行机制设计审查

Liquity V2 架构:核心合约和功能

  • 核心合约的结构如下:
    • 有一个 CollateralRegistry、一个 BoldToken 和一组核心系统合约,这些合约是为每个抵押品“分支”部署的。
    • CollateralRegistry 负责将外部 ERC20 抵押代币映射到相应的 TroveManager 地址。它还处理跨各种抵押品分支的赎回路由。
    • 示例:每个 WETH、rETH 和 wstETH 都有其自己专用的抵押品分支,其中包含管理 Trove、清算 Trove、处理 Stability Pool 存款以及促进特定于该分支的赎回所需的所有逻辑。

Liquity V2 抵押品注册表和 Trove 结构

  • 顶层合约
    • CollateralRegistry – 跟踪所有 LST 抵押品,并将特定于分支的 TroveManager 映射到这些抵押品。它计算赎回费用,并将 BOLD 赎回定向到不同分支的相应 TroveManager,与其未偿债务成正比。
    • BOLDToken – 这是实现 ERC20 标准并包含 EIP-2612 许可功能的稳定币代币合约。该合约负责铸造、销毁和转移 BOLD 代币。
  • 分支级别合约
    • BorrowerOperations – 包含借款人和管理器与他们的 Trove 交互的核心功能。在 Liquity V2 中,BorrowerOperations 处理这些功能,同时为借款人提供更大的灵活性。BorrowerOperations 函数调用 TroveManager 来更新 Trove 的状态并与各种池交互,在池之间或池与用户之间移动抵押品和 BOLD。它还指示 ActivePool 铸造利息。
    • TroveManager – 负责清算、赎回和计算各个 Trove 的利息。它保持每个 Trove 的状态,包括抵押品、债务和利率。但是,TroveManager 本身不持有任何价值(抵押品或 BOLD)。它在需要时调用各种池来移动抵押品或 BOLD。
    • TroveNFT – 在 TroveManager 的控制下实现 Trove NFT 的基本铸造和销毁功能。它还实现了 tokenURI 功能,该功能为每个 Trove 提供元数据,包括唯一图像。
    • LiquityBase – 包含 CollateralRegistryTroveManagerBorrowerOperationsStabilityPool 使用的共享函数。
    • StabilityPool – 管理 Stability Pool 操作,例如存款、取款复利存款、抵押品以及来自清算的 BOLD 收益。它持有 Stability Pool BOLD 存款、收益和分支上所有存款人的清算抵押品。
    • SortedTroves – 一个双向链表,用于存储 Trove 所有者的地址,按其年利率排序。它根据利率自动在正确的位置插入和重新插入 Trove。该合约还处理插入或重新插入成批的 Trove,这些 Trove 被建模为双向链表的切片。
    • ActivePool – 持有分支的抵押品余额,并跟踪活跃 Trove 的 BOLD 总债务。它铸造总利息,在 StabilityPool 和收益路由器(目前,用于 DEX LP 激励的 MockInterestRouter)之间分配。
    • DefaultPool – 持有已清算 Trove 的抵押品余额和 BOLD 债务,等待重新分配给活跃 Trove。如果活跃 Trove 在 DefaultPool 中有待处理的抵押品和债务“奖励”,则会在其下次借款人操作、赎回或清算期间将其应用于该 Trove。
    • CollSurplusPool – 跟踪和持有来自已清算 Trove 的抵押品盈余。它在索赔时分配借款人累积的盈余。
    • GasPool – 管理 WETH 气体补偿。当 Trove 打开时,WETH 会从借款人转移到 GasPool,然后在 Trove 被清算或关闭时支付。
    • MockInterestRouter – 一个占位符合约,目前接收铸造利息的 LP 收益分成。稍后它将被一个真正的收益路由器取代,该路由器将收益导向 DEX LP 激励。
  • 外围辅助合约
    • HintHelpers – 一个辅助合约,提供只读功能来计算准确的提示,这些提示可以提供给借款人操作。
    • MultiTroveGetter – 一个辅助合约,提供只读功能来获取 Trove 数据结构的数组,其中包含每个 Trove 的完整记录状态。
  • Oracle 价格馈送合约 由于 LST 之间价格计算方法不同,因此使用不同的 PriceFeed 合约为不同分支上的抵押品定价。但是,核心功能在父合约之间共享。更多信息可以在此处阅读。
    • MainnetPriceFeedBase:从 Chainlink(或 Redstone)oracle 获取价格并处理 oracle 故障。
    • CompositePriceFeed:结合来自 LST-ETH 和 ETH-USD oracle 的价格来计算综合 LST-USD 价格。
    • WETHPriceFeed:获取 WETH 抵押品的 ETH-USD 价格。
    • WSTETHPriceFeed:使用 STETH-USD 和 WSTETH-STETH 汇率计算 WSTETH-USD 价格。
    • RETHPriceFeed:使用 CompositePriceFeed 获取 RETH 抵押品的 RETH-ETH 汇率。

Liquity V2 的新功能?主要升级和功能

  • 多抵押品系统: 在 Liquity V2 中,该系统现在作为一个多抵押品框架运行,用于跨抵押品分支的去中心化借贷,包括 CollateralRegistry 和多个抵押品分支。每个分支都独立配置了自己的最低抵押率 (MCR)、临界抵押率 (CCR) 和关闭抵押率 (SCR)。每个分支也有自己的 TroveManagerStabilityPool,分支中的 Trove 只接受一种类型的抵押品。分支中的清算仅针对其对应的 Stability Pool 进行抵消,并且存款人的任何清算收益都以该特定抵押品支付。来自清算的抵押品和债务的重新分配也仅适用于同一分支内的活跃 Trove。CollateralRegistry 构造函数最多可以接受 10 个 TroveManager 及其各自的抵押品类型。但是,目前只有 WETH 和两个 LST(rETH 和 wstETH)可用作抵押品,而原生 ETH 不被接受。
1constructor(IBoldToken _boldToken, IERC20Metadata[] memory _tokens, ITroveManager[] memory _troveManagers)
  • 用户设定的利率: 借款人可以在开立 Trove 时选择自己的年利率,并可以选择随时调整它。简单的非复利利息会持续地累积到他们的债务上,并且只有在与 Trove 交互时才会复利。累积的总债务会定期铸造为 BOLD。默认情况下不选择最低利率,因为赎回优先考虑具有最低利率的 Trove,如本文后面所述。
1/* Sum of individual recorded Trove debts weighted by their respective chosen interest rates.
2* Updated at individual Trove operations.
3* "S" in the spec.
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}
  • 利息收益分配: 在 Liquity V2 中,利息收益分配给 Stability Pool (SP) 和流动性提供者 (LP)。从 Trove 利息产生的 BOLD 会定期分配,其中一部分进入 SP,另一部分通过收益路由器路由到 DEX LP 激励。特定分支的利息收益始终支付给同一分支上的 SP。分配发生在 ActivePool 中的 _mintAggInterest 内部
1function _mintAggInterest(IBoldToken _boldToken, uint256 _upfrontFee) internal returns (uint256 mintedAmount) {
2    mintedAmount = calcPendingAggInterest() + _upfrontFee;
3
4    // Mint part of the BOLD interest to the SP and part to the router for LPs.
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 内部调用,该函数由所有状态更改用户操作触发,包括借款人操作、清算、赎回和 Stability Pool 存款/取款。如果用户的操作改变了 Trove 的债务,则总累积债务会通过总未决利息和 Trove 债务净变化来更新。

1function mintAggInterestAndAccountForTroveChange(TroveChange calldata _troveChange, address _batchAddress)
2    external
3{
4    _requireCallerIsBOorTroveM();
5
6    // Batch management fees
7    if (_batchAddress != address(0)) {
8        _mintBatchManagementFeeAndAccountForChange(boldToken, _troveChange, _batchAddress);
9    }
10
11    // Do the arithmetic in 2 steps here to avoid overflow from the decrease
12    uint256 newAggRecordedDebt = aggRecordedDebt; // 1 SLOAD
13    newAggRecordedDebt += _mintAggInterest(boldToken, _troveChange.upfrontFee); // adds minted agg. interest + upfront fee
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 Hook,而次要目标是减少抵押不足程度最高的分支的未支持程度,使其超过抵押良好的分支。未支持程度是指分支的总 BOLD 债务与其 Stability Pool 中持有的 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    // It's redeemable if the TCR is above the shutdown threshold, and branch has not been shut down
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    // Gather and accumulate unbacked portions
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    // Compute redemption amount for each collateral and redeem against the corresponding 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}
  • 赎回顺序: 执行赎回的分支是根据其未支持程度来选择的,而在每个分支中,赎回的目标 Trove 是按其年利率排序的,从最低的开始,然后移动到最高的。与那些具有较低利率的 Trove 相比,具有较高利率的 Trove 更能免受赎回的影响,因为它们有更多的“前置债务”。在赎回顺序中不考虑 Trove 的抵押率。循环访问 Trove 的实际逻辑在 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    // Loop through the Troves starting from the one with lowest collateral ratio until _amount of Bold is exchanged for collateral
11    if (_maxIterations == 0) _maxIterations = type(uint256).max;
12    while (singleRedemption.troveId != 0 && remainingBold > 0 && _maxIterations > 0) {
13        _maxIterations--;
14        // Save the uint256 of the Trove preceding the current one
15        uint256 nextUserToCheck = sortedTrovesCached.getPrev(singleRedemption.troveId);
16        // Skip if ICR < 100%, to make sure that redemptions always improve the CR of hit Troves
17        if (getCurrentICR(singleRedemption.troveId, _price) < _100pct) {
18            singleRedemption.troveId = nextUserToCheck;
19            continue;
20        }
21
22              ...
23    }
24
25      ...
26}
  • 不可赎回的 Trove: 赎回不再关闭 Trove,而是保持其打开状态。如果赎回将 Trove 的 BOLD 债务减少到零或低于 MIN_DEBT 阈值,则将其标记为不可赎回,以防止赎回恶意攻击。一旦借款人将债务增加回 MIN_DEBT 阈值以上,这些 Trove 就可以再次赎回。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    // Make Trove unredeemable if it's tiny, in order to prevent griefing future (normal, sequential) redemptions
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}
  • 由 NFT 表示的 Trove: Trove 可以自由转移,并且可以由多个以太坊地址拥有,每个 Trove 由相应的 NFT 表示。一个地址可以通过拥有这些 NFT 来持有多个 Trove。当在 TroveManager打开 Trove 时,铸造 NFT,当关闭 Trove 时,销毁 NFT。
  • 个人委托: 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 所有者可以委托一个批量管理器来管理他们的利率。批量管理器能够在预定义的范围内调整批次中所有 Trove 的利率,该范围由管理器在注册期间设置。批量利率调整有效地更新批次中所有 Trove 的利率,从而最大限度地降低 gas 成本。
    • 批量结构: 批量被建模为 SortedTroves 列表中的切片,利用具有 head 和 tail 属性的新 Batch 数据结构。当批量管理器更新利率时,整个批量会根据新的利率重新插入到列表中的适当位置。为了简化 gas 成本,批量被视为“共享 Trove”,系统会跟踪批量的总债务和利率。利息和管理费会随着时间的推移而累积,每个 Trove 的重新分配收益都会单独跟踪。
    • 批量管理费: 批量每年累积管理费,其计算方式与利息类似。批量的记录债务会在以下情况下更新: 利息和管理费会添加到批量的记录债务中,以及因 Trove 交互而产生的任何个人更改。
    • 借款人调整其 Trove 的债务。
    • 批量管理器更改利率。
    • 批量中 Trove 的未决债务已应用。
    • 过早调整费: 如果批量管理器在冷却期结束前更改利率,则他们需要支付过早调整费,类似于个人 Trove。批量中的借款人依赖于管理器的能力来避免过多的费用。有能力的批量管理器有望建立声誉并吸引更多借款人,而表现不佳的管理器可能会看到他们的批量清空。
    • 批量不变性: 批量 Trove 的功能与个人 Trove 相同。如果两个 Trove(一个在批量中,一个独立)具有相同的状态,则在执行相同的操作(例如调整抵押品、债务或接收重新分配收益)之后,它们将保持相同。
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}

当批量的利率更新时,它会自动影响该批量中的所有 trove:

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 函数在管理个人 trove 与它们所属的批量之间的关系方面起着关键作用。此函数确保与 trove 关联的债务和抵押品准确地反映在相应的批量中。

onOpenTroveAndJoinBatch()onAdjustTroveInsideBatch()onRegisterBatchManager()onLowerBatchManagerAnnualFee()onSetBatchManagerAnnualInterestRate()onSetInterestBatchManager()onRemoveFromBatch() 负责在 trove 属于批量时更新 trove,并更新批量管理器相关的变量。

在执行赎回时,会针对属于批量的 Trove 进行特定处理:

1if (isTroveInBatch) {
2    _getLatestBatchData(_singleRedemption.batchAddress, _singleRedemption.batch);
3    // We know boldLot <= trove entire debt, so this subtraction is safe
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    // Mint batch management fee
6    troveChange.batchAccruedManagementFee = batch.accruedManagementFee;
7    activePool.mintBatchManagementFeeAndAccountForChange(troveChange, batchAddress);
8}

当然,这些并不是唯一考虑批量的情况,但它们可以作为一个很好的例子。

  • 抵押品分支关闭:在极端情况下,例如抵押品市场价格大幅下跌或预言机故障,抵押品分支将被关闭。这将导致冻结所有借款人操作(除了 Trove 关闭),停止利息累积,并启用紧急赎回,无需赎回费,并为赎回人提供少量抵押品奖励。目标是迅速减少受影响分支的债务。当总抵押品比率(TCR)低于或等于关闭抵押品比率(SCR)时,可以关闭一个分支。在这种情况下,任何人都可以调用 BorrowerOperations 合约中的 shutdown 函数,然后关闭所有其他分支合约,例如 TroveManagerActivePool。关闭的另一种情况是当预言机发生故障时(revert 或返回 0)。在这种情况下,继承自 MainnetPriceFeedBase 的合约将通过调用 BorrowerOperations 中的 shutdownFromOracleFailure 函数来触发关闭,通过它们的 _disableFeedAndShutDown 函数。
    • 关闭时
    • 所有待处理的聚合利息和批量管理费都会被应用和铸造。
    • 此后,不会再铸造或累积更多的聚合利息或批量管理费。
    • 单个 Trove 停止累积利息,累积的利息仅计算到关闭时间戳。
    • 批量停止累积利息和管理费,所有计算仅反映到关闭时间戳为止的值。
    • 关闭逻辑 在关闭期间,以下操作是不允许的:
    • 打开一个新的 Trove。
    • 调整 Trove 的债务、抵押品或利率。
    • 应用 Trove 的利息。
    • 调整批量的利率。
    • 应用批量的利息和管理费。
    • 正常赎回。
    • 关闭期间允许的操作
    • 关闭 Trove。
    • 清算 Trove。
    • 存入和提取 Stability Pool (SP)。
    • 紧急赎回(如下详述)。
    • 紧急赎回 在关闭期间,赎回逻辑被修改,以激励迅速减少分支的债务,即使 BOLD 的交易价格为 1 美元。紧急赎回:
    • 直接通过已关闭分支的 TroveManager 执行,并且仅影响该分支,而不会跨其他分支路由。
    • 不收取赎回费。
    • 向赎回人提供 1% 的抵押品奖励,这意味着每赎回 1 个 BOLD,赎回人将收到价值 1.01 美元的抵押品。
    • 不按利率顺序赎回 Trove。相反,赎回人传递一个要赎回的 Trove 列表。
    • 不会创建不可赎回的 Trove,即使 Trove 剩余的债务非常小或为零,以确保未来的紧急赎回不会因微小的 Trove 而受阻。

BorrowerOperations

1
2// Shutdown system collateral ratio. If the system's total collateral ratio (TCR) for a given collateral falls below the SCR,
3// the protocol triggers the shutdown of the borrow market and permanently disables all borrowing operations except for closing 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// Not technically a "Borrower op", but seems best placed here given current shutdown logic.
26function shutdownFromOracleFailure(address _failedOracleAddr) external {
27    _requireCallerIsPriceFeed();
28
29    // No-op rather than revert here, so that the outer function call which fetches the price does not revert
30    // if the system is already shut down.
31    if (hasBeenShutDown) return;
32
33    _applyShutdown();
34
35    emit ShutDownFromOracleFailure(_failedOracleAddr);
36}
37
38...
39
40function _applyShutdown() internal {
41    activePool.mintAggInterest();
42    hasBeenShutDown = true;
43    troveManager.shutdown();
44}
1
2// 关闭系统抵押品比率。如果给定抵押品的系统总抵押品比率 (TCR) 低于 SCR,
3// 该协议会触发借贷市场的关闭,并永久禁用除关闭 Trove 之外的所有借贷操作。
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// 从技术上讲,这并不是一个“Borrower op”,但鉴于当前的关闭逻辑,它似乎是最佳位置。
26function shutdownFromOracleFailure(address _failedOracleAddr) external {
27    _requireCallerIsPriceFeed();
28
29    // 这里使用 No-op 而不是 revert,以便获取价格的外部函数调用不会 revert
30    // 如果系统已经关闭。
31    if (hasBeenShutDown) return;
32
33    _applyShutdown();
34
35    emit ShutDownFromOracleFailure(_failedOracleAddr);
36}
37
38...
39
40function _applyShutdown() internal {
41    activePool.mintAggInterest();
42    hasBeenShutDown = true;
43    troveManager.shutdown();
44}

WETHPriceFeed

1function _fetchPrice() internal override returns (uint256, bool) {
2     (uint256 ethUsdPrice, bool ethUsdOracleDown) = _getOracleAnswer(ethUsdOracle);
3
4     // If the Chainlink response was invalid in this transaction, return the last good ETH-USD price calculated
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    // Shut down the branch
16    borrowerOperations.shutdownFromOracleFailure(_failedOracleAddr);
17
18    priceFeedDisabled = true;
19    return lastGoodPrice;
20}
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}
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}
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}
  • 移除恢复模式:之前的恢复模式逻辑已被移除。现在仅当 Trove 的单个抵押品比率 (ICR) 降至最低抵押品比率 (MCR) 以下时才会清算 Trove。但是,当特定分支的总抵押品比率 (TCR) 降至临界抵押品比率 (CCR) 以下时,借款限制仍然适用。这是因为某些操作可能会降低 ICR,进而使 TCR 低于 CCR。当 TCR 低于 CCR 时,借款受到限制,并且诸如设置利率或提取抵押品之类的操作受到限制,除非伴随有相等或更大价值的债务偿还。BorrowerOperations 中的 _requireNewTCRisAboveCCR 函数可确保在借款操作期间 TCR 保持健康:

BorrowerOperations

1function _requireValidAdjustmentInCurrentMode(
2    TroveChange memory _troveChange,
3    LocalVariables_adjustTrove memory _vars
4) internal view {
5    /*
6    * Below Critical Threshold, it is not permitted:
7    *
8    * - Borrowing
9    * - Collateral withdrawal except accompanied by a debt repayment of at least the same value
10    *
11    * In Normal Mode, ensure:
12    *
13    * - The adjustment won't pull the TCR below CCR
14    *
15    * In Both cases:
16    * - The new ICR is above MCR
17    */
18    _requireICRisAboveMCR(_vars.newICR);
19
20    if (_vars.isBelowCriticalThreshold) {
21        _requireNoBorrowing(_troveChange.debtIncrease);
22        _requireDebtRepaymentGeCollWithdrawal(_troveChange, _vars.price);
23    } else {
24        // if Normal Mode
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}
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}
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    // Differencen between liquidation penalty and liquidation threshold
12    if (singleLiquidation.collSurplus > 0) {
13        collSurplusPool.accountSurplus(owner, singleLiquidation.collSurplus);
14    }
15
16      ...
17}
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 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: No collateral available to claim");
5
6       ...
7
8    collToken.safeTransfer(_account, claimableColl);
9}
1function claimColl(address _account) external override {
2    _requireCallerIsBorrowerOperations();
3    uint256 claimableColl = balances[_account];
4    require(claimableColl > 0, "CollSurplusPool: No collateral available to claim");
5
6       ...
7
8    collToken.safeTransfer(_account, claimableColl);
9}
  • Gas 补偿:清算人现在可以通过抵押品和 WETH 的组合来补偿 gas 成本。清算储备始终以 WETH 计价,无论抵押品类型如何,并且包括抵押品形式的额外补偿。但是,此抵押品补偿设置了上限,以防止过度支付。_getCollGasCompensation 负责计算补偿金额:
1// Return the amount of Coll to be drawn from a trove's collateral and sent as gas compensation.
2function _getCollGasCompensation(uint256 _entireColl) internal pure returns (uint256) {
3    return LiquityMath._min(_entireColl / COLL_GAS_COMPENSATION_DIVISOR, COLL_GAS_COMPENSATION_CAP);
4}
1// 返回要从 trove 的抵押品中提取并作为 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}
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 函数用于从 ActivePoolGasPool 合约中提取 gas,GasPool 合约的唯一目的是持有 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}
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 奖励申领的更高灵活性: Stability Pool (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 _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 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}
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 V2 和 V1 主要功能对比

Liquity V2 中的安全考虑因素:风险和缓解策略

预防 Liquity V2 中的预言机抢跑交易

在 Liquity V2 去中心化借贷中,推送预言机用于抵押品定价,这可能容易受到攻击。推送预言机用于抵押品定价,这可能容易受到抢跑交易攻击。在这种攻击中,攻击者可以观察到来自预言机的即将到来的价格上涨在 mempool 中,在更新被处理之前执行赎回交易,然后在价格上涨被验证后以更高的价格出售赎回的抵押品。这允许攻击者提取超出典型套利收益的利润。这些漏洞最好通过 L1 & L2 协议审计 来解决。

在 Liquity v1 中,这个问题通过 0.5% 的最低赎回费来缓解,与 Chainlink 的 ETH-USD 预言机更新阈值 0.5% 相匹配。在 v2 中,一些 LST-ETH 预言机具有更大的更新阈值(例如,Chainlink 的 RETH-ETH 为 2%),但由于这些 feed 的稳定性,预计抢跑交易的问题会减少,这些 feed 通常基于心跳而不是价格偏差进行更新。

赎回路由操纵:Liquity V2 如何确保公平性

赎回路由逻辑通过相同的百分比减少每个分支的“外部”债务,其中分支的外部债务计算为:

outside_debt_i = bold_debt_i - bold_in_SP_i

这允许赎回人通过存入他们不希望赎回的分支的 Stability Pool (SP) 来暂时操纵某些分支的外部债务。这种策略将赎回导向赎回人想要的分支,允许他们以可能在外部市场中具有较低滑点的特定 Liquid Staking Tokens (LST) 为目标。

攻击者可以通过将 BOLD 存入不需要的分支的 SP 中,并在同一交易中从选择的分支赎回来实现此目的,这可以通过闪电贷来提供资金。这就是事件与紧急响应 计划的重要性。

解决方案: 目前,没有修复措施,因为:

  1. 赎回套利竞争非常激烈,闪电贷费用会减少攻击者的利润。
  2. 操纵不会从系统中提取直接价值。
  3. 赎回路由是一种软性措施,旨在推动系统朝着更好的健康状态发展,但对于系统稳定性而言并非至关重要,而系统稳定性主要依赖于抵押品市场的健康状况。

路径依赖性赎回费用:优化 Liquity V2 中的成本

Liquity v2 中的赎回费用是路径依赖的。这意味着与将相同金额分成多个较小的交易赎回相比,在一个交易中赎回大量 BOLD 会产生更高的费用(假设交易之间系统状态没有变化)。因此,赎回人可能会被激励将赎回分成更小的块,以最大程度地减少总费用。

示例

可以在此 电子表格 中找到说明此费用结构的示例。

解决方案

出于以下原因,认为没有必要进行修复:

  1. 竞争性套利:赎回
  • 原文链接: threesigma.xyz/blog/lend...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Three Sigma
Three Sigma
Three Sigma is a blockchain engineering and auditing firm focused on improving Web3 by working closely with projects in the space.