Exploring Liquity V2: A New Era of Decentralized Borrowing
Danail Yordanov
29 min read

At Three Sigma, we recognize the intricate opportunities within the Web3 space. Based in Lisbon, our team of experts offers premier services in development, security, and economic modeling to drive your project's success. Whether you need precise code audits, advanced economic modeling, or comprehensive blockchain engineering, Three Sigma is your trusted partner.

Explore our website to learn more.

We are not just experts in code, economic auditing, and blockchain engineering—we are passionate about breaking down Web3 concepts to drive clarity and adoption across the industry.

Introduction

Liquity V1 was built to be immutable, governed solely by code. Since its launch in April 2021, it has functioned seamlessly, offering 0% interest rates while ensuring the security of users' funds. With the release of Liquity V2, the platform takes a bold step forward, introducing a new feature: user-set interest rates, creating a more efficient and attractive market for both borrowers and stablecoin holders.

Liquity V2 is a decentralized collateralized debt platform that allows users to lock up assets like WETH or Liquid Staking Tokens (LSTs) to issue its stablecoin token, known as BOLD. BOLD is designed to maintain a value of $1 USD by ensuring that the system is always over-collateralized and that BOLD can always be redeemed for a corresponding amount of protocol collateral.

The system enables users to open collateralized debt positions, called Troves, by depositing ERC20 tokens as collateral. BOLD tokens can be borrowed against the collateral as long as the collateralization ratio stays above the minimum required. BOLD is freely transferable across Ethereum addresses and can be redeemed by anyone for a dollar's worth of collateral, minus fees.

Architecture

  • The core contracts are structured as follows:
    • There is one CollateralRegistry, a single BoldToken, and a collection of core system contracts that are deployed for each collateral "branch."
    • The CollateralRegistry is responsible for mapping external ERC20 collateral tokens to a corresponding TroveManager address. It also handles the routing of redemptions across the various collateral branches.
    • Example: Each WETH, rETH and wstETH have its own dedicated collateral branch, which contains all the necessary logic for managing Troves, liquidating them, handling Stability Pool deposits, and facilitating redemptions specific to that branch.
  • Top-level Contracts
    • CollateralRegistry – Keeps track of all LST collaterals and maps branch-specific TroveManagers to these collaterals. It calculates redemption fees and directs BOLD redemptions to the appropriate TroveManagers across different branches, proportional to their outstanding debt.
    • BOLDToken – This is the stablecoin token contract that implements the ERC20 standard and includes EIP-2612 permit functionality. The contract is responsible for minting, burning, and transferring BOLD tokens.
  • Branch-level Contracts
    • BorrowerOperations – Contains the core functions for borrowers and managers to interact with their Troves. This includes creating Troves, adding or withdrawing collateral, issuing and repaying BOLD, and adjusting interest rates. BorrowerOperations functions call the TroveManager to update the Trove’s state and interact with the various Pools, moving collateral and BOLD between Pools or between the Pool and user. It also instructs the ActivePool to mint interest.
    • TroveManager – Responsible for liquidations, redemptions, and calculating interest for individual Troves. It keeps the state of each Trove, including the collateral, debt, and interest rate. However, the TroveManager itself does not hold any value (collateral or BOLD). It calls various Pools to move collateral or BOLD when needed.
    • TroveNFT – Implements basic minting and burning functionality for Trove NFTs, under the control of TroveManager. It also implements the tokenURI functionality, which provides metadata for each Trove, including a unique image.
    • LiquityBase – Contains shared functions used by CollateralRegistry, TroveManager, BorrowerOperations, and StabilityPool.
    • StabilityPool – Manages Stability Pool operations like deposits, and withdrawal of compounded deposits, collateral, and BOLD gains from liquidations. It holds the Stability Pool BOLD deposits, yield gains, and liquidation collateral for all depositors on a branch.
    • SortedTroves – A doubly linked list that stores the addresses of Trove owners, sorted by their annual interest rate. It automatically inserts and re-inserts Troves in the correct position based on interest rates. The contract also handles inserting or re-inserting batches of Troves, modeled as slices of the doubly linked list.
    • ActivePool – Holds the collateral balance for a branch and tracks the total BOLD debt of the active Troves. It mints aggregate interest, split between the StabilityPool and a yield router (currently, MockInterestRouter for DEX LP incentives).
    • DefaultPool – Holds the collateral balance and BOLD debt of liquidated Troves pending redistribution to active Troves. If an active Trove has collateral and debt "rewards" pending in the DefaultPool, they are applied to the Trove during its next borrower operation, redemption, or liquidation.
    • CollSurplusPool – Tracks and holds collateral surpluses from liquidated Troves. It distributes a borrower's accumulated surplus when claimed.
    • GasPool – Manages WETH gas compensation. When a Trove is opened, WETH is transferred from the borrower to the GasPool and is then paid out when a Trove is liquidated or closed.
    • MockInterestRouter – A placeholder contract that currently receives the LP yield split of minted interest. It will later be replaced by a real yield router that directs yield to DEX LP incentives.
  • Peripheral Helper Contracts
    • HintHelpers – A helper contract that provides read-only functionality to calculate accurate hints, which can be supplied to borrower operations.
    • MultiTroveGetter – A helper contract that offers read-only functionality to fetch arrays of Trove data structs, containing the complete recorded state of each Trove.
  • Oracle Price Feed Contracts Different PriceFeed contracts are used to price collaterals on different branches due to varying price calculation methods across LSTs. However, core functionality is shared among parent contracts. More info can be read here.
    • MainnetPriceFeedBase: Fetches prices from Chainlink (or Redstone) oracles and handles oracle failure.
    • CompositePriceFeed: Combines prices from LST-ETH and ETH-USD oracles to calculate a composite LST-USD price.
    • WETHPriceFeed: Fetches ETH-USD price for WETH collateral.
    • WSTETHPriceFeed: Computes WSTETH-USD price using STETH-USD and WSTETH-STETH exchange rates.
    • RETHPriceFeed: Uses CompositePriceFeed to fetch the RETH-ETH rate for RETH collateral.

What's new in V2

  • Multi-collateral system: The system now operates as a multi-collateral framework, consisting of a CollateralRegistry and multiple collateral branches. Each branch is independently configured with its own Minimum Collateral Ratio (MCR), Critical Collateral Ratio (CCR), and Shutdown Collateral Ratio (SCR). Each branch also has its own TroveManager and StabilityPool, with Troves in a branch only accepting a single type of collateral. Liquidations in a branch are offset solely against its corresponding Stability Pool, and any liquidation gains for depositors are paid in that specific collateral. Redistribution of collateral and debt from liquidations also applies only to active Troves within the same branch. The CollateralRegistry constructor can accept up to 10 TroveManagers along with their respective collateral types. However, currently, only WETH and two LSTs—rETH and wstETH—are available as collateral, while native ETH is not accepted.
1constructor(IBoldToken _boldToken, IERC20Metadata[] memory _tokens, ITroveManager[] memory _troveManagers)
  • User-set interest rates: Borrowers can select their own annual interest rate when opening a Trove, with the option to adjust it at any time. Simple, non-compounding interest accrues continuously on their debt and is compounded only when the Trove is interacted with. The total accrued debt is periodically minted as BOLD. The lowest interest rate isn't chosen by default because redemptions are prioritized for Troves with the lowest interest rates first, as explained later in the article.
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}
  • Interest Yield Distribution: Yield from interest is distributed to both the Stability Pool (SP) and liquidity providers (LPs). BOLD generated from Trove interest is periodically split, with one portion going to the SP and the other routed to DEX LP incentives via a yield router. Interest yield for a specific branch is always paid to the SP on that same branch. The distribution occurs inside _mintAggInterest in the ActivePool
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}

This function is called inside mintAggInterestAndAccountForTroveChange which is triggered by all state-changing user operations, including borrower operations, liquidations, redemptions, and Stability Pool deposits/withdrawals. If a user's operation alters the Trove’s debt, the aggregate recorded debt is updated by the total pending interest and the net Trove debt change.

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}
  • Redemption routing: BOLD redemptions are managed by the CollateralRegistry and are distributed across branches based on their level of "unbackedness." The redemption volume directed to each branch is proportional to how unbacked it is. The primary goal of redemptions is to restore the BOLD peg, while the secondary goal is to reduce the unbackedness of the most undercollateralized branches more than the better-backed ones. Unbackedness refers to the difference between a branch's total BOLD debt and the BOLD held in its Stability Pool.
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}
  • Redemption ordering: Branches where redemptions are executed are selected based on their level of unbackedness, while within each branch, redemptions target Troves in order of their annual interest rate, starting with the lowest and moving to the highest. Troves with higher interest rates are more protected from redemptions, as they have more "debt-in-front" of them compared to those with lower interest rates. The collateral ratio of a Trove is not considered in the redemption order. The actual logic for looping over the Troves is executed inside redeemCollateral in TroveManager
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}
  • Unredeemable Troves: Redemptions no longer close Troves but leave them open. If a redemption reduces a Trove's BOLD debt to zero or below the MIN_DEBT threshold, it is marked as unredeemable to prevent a redemption griefing attack. These Troves can become redeemable again once the borrower increases the debt back above the MIN_DEBT threshold. The function _redeemCollateralFromTrove in TroveManager which is called inside redeemCollateral is tasked to mark a Trove as unredeemable.
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}
  • Troves represented by NFTs: Troves are freely transferable and can be owned by multiple Ethereum addresses, with each Trove represented by a corresponding NFT. An address can hold multiple Troves through ownership of these NFTs. NFTs are minted when opening a Trove in TroveManager and burned when closing a Trove.
  • Individual delegation: A Trove owner can delegate an individual manager to manage their Trove, allowing the manager to set the interest rate and make adjustments to debt and collateral. The delegation is done via the setInterestIndividualDelegate function in BorrowerOperations. The following function is used to check if a particular delegate is eligible to change the interest rate:
1function _requireSenderIsOwnerOrInterestManager(uint256 _troveId, address _owner) internal view {
2    if (msg.sender != _owner && msg.sender != interestIndividualDelegateOf[_troveId].account) {
3        revert NotOwnerNorInterestManager();
4    }
5}
  • Batch delegation: A Trove owner can delegate a batch manager to manage their interest rate. The batch manager has the ability to adjust the interest rates for all Troves in the batch within a predefined range, which is set by the manager during registration. A batch interest rate adjustment efficiently updates the interest rates for all Troves in the batch, minimizing gas costs.
    • Batch Structure: Batches are modeled as slices in the SortedTroves list, utilizing the new Batch data structure with head and tail properties. When a batch manager updates the interest rate, the entire batch is reinserted into its appropriate position in the list based on the new interest rate. To streamline gas costs, batches are treated as "shared Troves," where the system tracks the total debt and interest rate for the batch. Interest and management fees accrue over time, with each Trove’s redistribution gains tracked individually.
    • Batch Management Fees: Batches accrue management fees annually, calculated similarly to interest. The recorded debt of a batch is updated when: Interest and management fees are added to the batch’s recorded debt, along with any individual changes resulting from Trove interactions.
      • A borrower adjusts their Trove's debt.
      • The batch manager changes the interest rate.
      • Pending debt on a Trove within the batch is applied.
    • Premature Adjustment Fees: Batch managers are subject to fees for premature adjustments if they alter interest rates before the cooldown period ends, similar to individual Troves. Borrowers in a batch rely on the manager’s competence to avoid excessive fees. Competent batch managers are expected to build reputations and attract more borrowers, while poor managers may see their batches empty.
    • Batch Invariant: Batch Troves function equivalently to individual Troves. If two Troves—one in a batch and one independent—have identical states, they will remain identical after performing the same operations, such as adjusting collateral, debt, or receiving redistribution gains.
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}

When a batch's interest rate is updated, it automatically affects all troves within that batch:

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}


The _updateBatchShares function plays a key role in managing the relationship between individual troves and the batches they belong to. This function ensures that the debt and collateral associated with a trove are accurately reflected in the corresponding batch.

onOpenTroveAndJoinBatch() , onAdjustTroveInsideBatch(), onRegisterBatchManager(), onLowerBatchManagerAnnualFee(), onSetBatchManagerAnnualInterestRate(), onSetInterestBatchManager(), onRemoveFromBatch() are responsible for updating troves when they belong to a batch and updating the batch manager related variables.

When performing a redemption, specific handling is in place for Troves that are part of batches:

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...


The liquidation mechanism also takes into account whether a trove is part of a batch:

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}


Of course, these aren't the only instances where batches are taken into account, but they serve as a good example.

  • Collateral branch shutdown: In extreme situations, such as a significant collapse in the collateral market price or an oracle failure, a collateral branch will be shut down. This results in freezing all borrower operations (except for Trove closures), halting interest accrual, and enabling urgent redemptions with no redemption fee and a small collateral bonus for the redeemer. The goal is to rapidly reduce the debt from the affected branch. A branch can be shut down when the Total Collateral Ratio (TCR) falls below or is equal to the Shutdown Collateral Ratio (SCR). In this case, anyone can call the shutdown function in the BorrowerOperations contract, which then shuts down all other branch contracts, such as the TroveManager and ActivePool. The other scenario for shutdown occurs when an oracle fails (reverts, or returns 0). In this case, contracts inheriting from MainnetPriceFeedBase will trigger the shutdown by calling the shutdownFromOracleFailure function in BorrowerOperations through their _disableFeedAndShutDown function.
    • Upon Shutdown
      • All pending aggregate interest and batch management fees are applied and minted.
      • Afterward, no further aggregate interest or batch management fees are minted or accrued.
      • Individual Troves stop accruing interest, with the accrued interest calculated only up to the shutdown timestamp.
      • Batches stop accruing interest and management fees, with all calculations only reflecting values up to the shutdown timestamp.
    • Shutdown Logic During shutdown, the following operations are disallowed:
      • Opening a new Trove.
      • Adjusting a Trove’s debt, collateral, or interest rate.
      • Applying a Trove’s interest.
      • Adjusting a batch’s interest rate.
      • Applying a batch’s interest and management fees.
      • Normal redemptions.
    • Allowed Operations During Shutdown
      • Closing a Trove.
      • Liquidating Troves.
      • Depositing to and withdrawing from the Stability Pool (SP).
      • Urgent redemptions (as detailed below).
    • Urgent Redemptions During a shutdown, redemption logic is modified to incentivize a swift reduction of the branch's debt, even if BOLD is trading at $1 USD. Urgent redemptions:
      • Are executed directly through the shut-down branch’s TroveManager and affect only that branch, without being routed across other branches.
      • Charge no redemption fee.
      • Provide a 1% collateral bonus to redeemers, meaning for every 1 BOLD redeemed, the redeemer receives $1.01 worth of collateral.
      • Do not redeem Troves in order of interest rate. Instead, the redeemer passes a list of Troves to redeem from.
      • Do not create unredeemable Troves, even if a Trove is left with a very small or zero debt, ensuring future urgent redemptions are not hindered by tiny Troves.

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}

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}

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}
  • Removal of Recovery Mode: The previous Recovery Mode logic has been removed. Troves are now only liquidated if their individual collateral ratio (ICR) drops below the minimum collateral ratio (MCR). However, borrowing restrictions still apply when the total collateral ratio (TCR) falls below the critical collateral ratio (CCR) for a specific branch. This is because certain operations can lower the ICR and, in turn, the TCR below the CCR. When the TCR is below the CCR, borrowing is restricted, and actions like setting interest rates or withdrawing collateral are limited, except when accompanied by a debt repayment of equal or greater value. The _requireNewTCRisAboveCCR function in BorrowerOperations ensures that the TCR remains healthy during borrowing operations:

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}

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}
  • Liquidation penalties: Borrowers who are liquidated no longer always forfeit their entire collateral. Depending on the specific collateral branch and the type of liquidation, they may be able to recover a small portion of their remaining collateral. _liquidate in TroveManager transfers the surplus collateral to the 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}


Where the borrower can call claimColl at CollSurplusPool to claim his remaining collateral:

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}
  • Gas compensation: Liquidators are now compensated for gas costs with a mix of collateral and WETH. The liquidation reserve is always denominated in WETH, regardless of the collateral type, and includes additional compensation in the form of collateral. However, this collateral compensation is capped to prevent excessive payouts. _getCollGasCompensation is responsible for calculating the amount of compensation:
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}
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}


The _sendGasCompensation function is used to pull the gas from the ActivePool or GasPool contract, whose sole purpose is to hold gas reserves for paying liquidators.

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}
  • More flexibility for SP reward claiming: Stability Pool (SP) depositors now have the option to either claim or stash their LST gains from liquidations. Additionally, they can choose to claim their BOLD yield gains or automatically add them to their existing deposit.
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}


Security Perspective

Oracle Frontrunning

Push oracles are used for collateral pricing, which can be vulnerable to frontrunning attacks. In such attacks, an attacker can observe an upcoming price increase from the oracle in the mempool, execute a redemption transaction before the update is processed, and then sell the redeemed collateral at a higher price after the price increase is validated. This allows the attacker to extract profits beyond typical arbitrage gains.

In Liquity v1, this issue was mitigated by a 0.5% minimum redemption fee, matching Chainlink's ETH-USD oracle update threshold of 0.5%. In v2, some LST-ETH oracles have larger update thresholds (e.g., 2% for Chainlink’s RETH-ETH), but frontrunning is expected to be less of an issue due to the stability of these feeds, which typically update based on a heartbeat rather than price deviations.

Redemption Routing Manipulation

The redemption routing logic reduces the "outside" debt of each branch by the same percentage, where outside debt for a branch is calculated as:

outside_debt_i = bold_debt_i - bold_in_SP_i

This allows a redeemer to temporarily manipulate the outside debt of certain branches by depositing into the Stability Pool (SP) of branches they don’t wish to redeem from. This tactic directs redemptions toward branches where the redeemer desires, allowing them to target specific Liquid Staking Tokens (LSTs) with potentially lower slippage in external markets.

An attacker could achieve this by depositing BOLD into unwanted branches’ SPs and redeeming from chosen branches in the same transaction, possibly funded through a flash loan.

Solution: Currently, no fix is in place because:

  1. Redemption arbitrage is highly competitive, and flash loan fees reduce the attacker's profits.
  2. The manipulation does not extract direct value from the system.
  3. Redemption routing is a soft measure intended to nudge the system toward better health but is not critical to system stability, which relies primarily on the health of collateral markets.

Path-Dependent Redemption Fee

The redemption fee in Liquity v2 is path-dependent. This means that redeeming a large amount of BOLD in one transaction incurs a higher fee compared to redeeming the same amount split across multiple smaller transactions (assuming no changes to the system state between transactions). As a result, redeemers may be incentivized to split their redemptions into smaller chunks to minimize the total fee.

Example:

An example illustrating this fee structure can be found in this spreadsheet.

Solution:

No fix is considered necessary for the following reasons:

  1. Competitive Arbitrage: Redemption arbitrage is competitive, and profit margins are slim. Splitting redemptions into smaller transactions increases the total gas cost, which reduces overall arbitrage profits.
  2. Proven Stability: The same fee formula was used in Liquity v1, where it functioned effectively in maintaining the BOLD peg.
  3. Non-Critical Optimization: The redemption fee formula’s parameters are "best-guess" estimates, and there’s no reason to assume that even the intended fee structure is perfectly optimal.

Oracle Failure and Branch Shutdown Consequences

When an oracle failure triggers a branch shutdown, the respective PriceFeed’s fetchPrice function defaults to the recorded lastGoodPrice for that branch's LST. After shutdown, this price is used for urgent redemptions, even though the real market price may differ, causing potential distortions:

Solution Status: No fix is currently in place for the following reasons:

  1. Over-Redemption (lastGoodPrice < market price): While urgent redemptions return too much collateral, they still help clear debt from the branch.
  2. Under-Redemption (lastGoodPrice > market price): In this case, some BOLD debt remains uncleared on the shut-down branch, resulting in unbacked debt. This is a known risk in a multi-collateral system and is dependent on the economic health of integrated LST assets. A solution for clearing bad debt remains to be implemented (see Branch shutdown and bad debt section).
  3. Oracle Failure Likelihood: Oracle failures are more likely to result from a disabled Chainlink feed rather than a technical issue or hack. A disabled LST oracle likely points to low liquidity or volume for that asset, which suggests it constitutes a small portion of the total Liquity v2 collateral.

Stale oracle price before shutdown triggered

Liquity v2 monitors the market oracle answers for staleness, and if they exceed a pre-set threshold, it triggers a shutdown for the associated branch. However, during the period between the last oracle update and the branch shutdown, the system continues to use the most recent (possibly outdated) oracle price. This can lead to pricing discrepancies that cause distortions, such as:

  • Profitable or unprofitable redemptions due to outdated collateral prices.
  • Under-collateralized borrowing, where users borrow BOLD with insufficient collateral.
  • Liquidation of healthy Troves if the collateral price is inaccurately low.

Solution: To minimize these risks, carefully selected staleness threshold parameters are essential. More volatile feeds like ETH-USD and STETH-USD should have lower staleness thresholds compared to LST-ETH feeds, as the USD feeds are more prone to significant price deviations. All staleness thresholds should be longer than the push oracle’s update heartbeat to avoid premature shutdowns.

Conclusion

Overall, Liquity V2 brings fresh innovation to the DeFi space, making its already successful predecessor even more competitive among lending protocols. Building on the strong foundation of the original, Liquity v2 is poised for success with its new features and improvements, offering benefits that will undoubtedly resonate with users. We look forward to a smooth and successful launch for the Liquity team!

Reach out to Three Sigma, and let our team of seasoned professionals guide you confidently through the Web3 landscape. Our expertise in smart contract security, economic modeling, and blockchain engineering, we will help you secure your project's future.

Contact us today and turn your Web3 vision into reality!

References

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