探索Liquity V2:去中心化借贷

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

"探索 Liquity V2:去中心化借贷 - Three Sigma" 横幅

我们不仅是代码、经济审计和区块链工程的专家——我们热衷于拆解 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 – 包含 CollateralRegistryTroveManagerBorrowerOperationsStabilityPool 使用的共享功能。
    • 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 合约来给不同分支的抵押品定价。然而,核心功能在父合约之间共享。更多信息可以在 这里 阅读。
    • MainnetPriceFeedBase:从 Chainlink(或 Redstone)预言机获取价格,并处理预言机失败。
    • CompositePriceFeed:将 LST-ETH 和 ETH-USD 的价格结合在一起,以计算复合 LST-USD 的价格。
    • WETHPriceFeed:获取 WETH 抵押的 ETH-USD 价格。
    • WSTETHPriceFeed:使用 STETH-USD 和 WSTETH-STETH 的汇率计算 WSTETH-USD 的价格。
    • RETHPriceFeed:使用 CompositePriceFeed 获取 RETH 抵押的 RETH-ETH 价格。

V2 中的新内容

  • 多抵押体系: 系统现在作为多抵押框架运行,由一个 CollateralRegistry 和多个抵押分支组成。每个分支都可以独立配置自己的 最低抵押比例(MCR)、临界抵押比例(CCR)和 关闭抵押比例(SCR)。每个分支还有自己的 TroveManagerStabilityPool,在某个分支里,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 的实际逻辑在 TroveManagerredeemCollateral 内执行。
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——一个在批量中,一个独立——在同一状态下,经过执行相同的操作后(如调整抵押、债务或接收重分配收益),它们将保持相同。
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}

_updateBatchShareshttps://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 函数,该函数会关闭所有其他分支合约,如 TroveManagerActivePool。关闭的另一个场景是在预言机失败时(回退或返回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 函数用于从 ActivePoolGasPool 合约中提取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,并在同一笔交易中从选择的分支赎回,来实现这一目标,可能资金通过闪电贷提供。

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

  1. 赎回套利竞争激烈,闪电贷费用减少了攻击者的利润。
  2. 操控并不从系统中提取直接价值。
  3. 赎回路由是一种软措施,目的是将系统推向更好的健康状态,但并不是系统稳定的关键,稳定性主要依赖于抵押市场的健康。

路径依赖的赎回费用

Liquity v2 中的赎回费用是路径依赖的。这意味着,在一笔交易中赎回大量的 BOLD 会产生比在多笔小额交易中赎回同样数量的 BOLD 更高的费用(假设交易之间系统状态没有发生变化)。因此,赎回者可能会受到激励,将赎回拆分为较小的部分,以最小化总费用。

示例

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

解决方案

认为没有必要修复以下原因:

  1. 竞争套利:赎回套利是竞争性的,利润空间很小。将赎回拆分为较小交易会增加总Gas成本,从而减少整体套利利润。
  2. 已证实的稳定性:在 Liquity v1 中使用了相同的费用公式,它有效地维持了 BOLD 的Hook。
  3. 非关键优化:赎回费用公式的参数是“最佳估计”,没有理由认为即使预定的费用结构是完美的最优。

预言机故障和分支关闭的后果

当预言机故障触发分支关闭时,该分支的 PriceFeed 的 fetchPrice 函数默认为该分支 LST 的记录 lastGoodPrice。在关闭后,此价格将用于紧急赎回,即使真实市场价格可能不同,这可能导致潜在的扭曲:

解决方案状态:目前没有解决方案,原因如下:

  1. 过度赎回(lastGoodPrice < 市场价格):虽然紧急赎回返回了过多的抵押品,但仍有助于清除分支的债务。
  2. 欠赎回(lastGoodPrice > 市场价格):在这种情况下,一些 BOLD 债务保持未清,导致无担保债务。这是多重抵押系统中已知的风险,依赖于集成 LST 资产的经济健康。解决坏债务的方案仍待实施(详见 分支关闭和坏债务 部分)。
  3. 预言机故障的可能性:预言机故障更可能是由于禁用了 Chainlink 数据源,而不是技术问题或黑客攻击。禁用的 LST 预言机很可能表明该资产流动性或交易量低,这意味着它仅占 Liquity v2 抵押品总额的一小部分。

关闭触发之前的陈旧预言机价格

Liquity v2 会监控市场预言机的答案是否过时,如果它们超过预设阈值,将触发相应分支的关闭。然而,在最后一次预言机更新与分支关闭之间,系统持续使用最近的(可能过时的)预言机价格。这可能导致价格差异,从而引发以下扭曲:

  • 由于过时的抵押品价格造成的有利或不利赎回
  • 抵押不充分的借款,用户以不足的抵押品借出 BOLD。
  • 健康 Troves 的清算,如果抵押品价格被错误低估。

解决方案:为最小化这些风险,必须精心选择过时阈值参数。更易波动的价格反馈如 ETH-USDSTETH-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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 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.