Back to Blog 

DeFiSolidityWeb3 Security
Liquity Protocol — DeFi Protocol Explained from Its Smart Contracts (Part 1)
11 min
Considering how much we have to learn about DeFi protocols in order to improve our auditing skills, here is the first part about Liquity protocol — explained directly from its smart contracts.
I finally decided to divide it in two as otherwise it might have been a massive article and I felt it could be separated to understand different concepts.
Content
Introduction
Liquity is a fully automated and governance-free decentralized borrowing protocol. It's designed to allow permissionless loaning of its native token and to draw interest-free loans against Ether used as collateral.
Liquity does not run its own Frontend. To interact with the protocol, users have to use third-party frontends. This is done to achieve maximum capital efficiency and user-friendliness.
Loans are paid out in LUSD and need to maintain a minimum collateral ratio of 110%.
In addition to the collateral, the loans are secured by a Stability Pool containing LUSD and by fellow borrowers collectively acting as guarantors of last resort.
Tokens
What is LUSD?
LUSD is a USD-pegged stablecoin that serves as the primary network token for the Liquity protocol. When users place their Ethereum into their account, or Trove, as it's known, they receive a loan in the form of LUSD.
The smart contracts involved:
LUSDToken.sol:
It is the stablecoin token contract, which implements the ERC20 fungible token standard in conjunction with EIP-2612 and a mechanism that blocks (accidental) transfers to addresses like the StabilityPool and
address(0) that are not supposed to receive funds through direct transfers.The contract mints, burns and transfers LUSD tokens.
In order to validate who is minting the tokens, we can notice in the
mint function that it's only going to be allowed to mint from the BorrowOperations contract:1function mint(address _account, uint256 _amount) external override {2 _requireCallerIsBorrowerOperations();3 _mint(_account, _amount);4}
And the main situation where new LUSD tokens will be minted is through
openTrove() function that is used to borrow.What is LQTY?
LQTY is the secondary network token of the Liquity borrowing system, and is given out as a reward and incentive to those who make the system work. These include the frontends that complete the transactions, contributors to the stability pool, and liquidity providers. These are the only ways to earn LQTY.
The LQTY contracts consist of:
LQTYStaking.sol:
The staking contract, containing stake and unstake functionality for LQTY holders. This contract receives ETH fees from redemptions and LUSD fees from new debt issuance.
CommunityIssuance.sol:
This contract handles the issuance of LQTY tokens to Stability Providers as a function of time.
1function issueLQTY() external override returns (uint) {2 _requireCallerIsStabilityPool();34 uint latestTotalLQTYIssued = LQTYSupplyCap5 .mul(_getCumulativeIssuanceFraction())6 .div(DECIMAL_PRECISION);7 uint issuance = latestTotalLQTYIssued.sub(totalLQTYIssued);89 totalLQTYIssued = latestTotalLQTYIssued;10 emit TotalLQTYIssuedUpdated(latestTotalLQTYIssued);1112 return issuance;13}
It is controlled by the StabilityPool. You can see that because of the use of
_requireCallerIsStabilityPool().The contract also issues these LQTY tokens to the Stability Providers over time. And is transferring them with:
1function sendLQTY(address _account, uint _LQTYamount) external override {2 _requireCallerIsStabilityPool();3 lqtyToken.transfer(_account, _LQTYamount);4}
LQTYToken.sol:
This is the LQTY ERC20 contract. It has a hard cap supply of 100 million. It is interesting how it is minting them:
1// Allocate 2 million for bounties/hackathons2_mint(_bountyAddress, bountyEntitlement);3uint bountyEntitlement = _1_MILLION.mul(2);45// Allocate 32 million to the algorithmic issuance schedule6uint depositorsAndFrontEndsEntitlement = _1_MILLION.mul(32);7_mint(_communityIssuanceAddress, depositorsAndFrontEndsEntitlement);89// Allocate 1.33 million for LP rewards10uint _lpRewardsEntitlement = _1_MILLION.mul(4).div(3);11lpRewardsEntitlement = _lpRewardsEntitlement;12_mint(_lpRewardsAddress, _lpRewardsEntitlement);1314// Allocate the remainder to the LQTY Multisig:15// (100 - 2 - 32 - 1.33) million = 64.66 million16uint multisigEntitlement = _1_MILLION.mul(100)17 .sub(bountyEntitlement)18 .sub(depositorsAndFrontEndsEntitlement)19 .sub(_lpRewardsEntitlement);2021_mint(_multisigAddress, multisigEntitlement);
What is a Trove?
A Trove is where you take out and maintain your loan. Each Trove is linked to an Ethereum address and each address can have just one Trove.
If you are familiar with Vaults or CDPs from other platforms, Troves are similar in concept.
1struct Trove {2 uint debt;3 uint coll;4 uint stake;5 Status status;6 uint128 arrayIndex;7}
Troves maintain two balances: one is an asset (ETH) acting as collateral, and the other is a debt denominated in LUSD.
You can change the amount of each by adding collateral or repaying debt.
You can close your Trove at any time by fully paying off your debt.
TroveManager.sol
Contains functionality for liquidations and redemptions.
In this enum, which is used in the events of the respective functions, we can see the names of some of the main functions:
1enum TroveManagerOperation {2 applyPendingRewards,3 liquidateInNormalMode,4 liquidateInRecoveryMode,5 redeemCollateral6}
There are different "Trove Liquidation functions":
1// Single liquidation function.2// Closes the trove if its ICR is lower than the minimum collateral ratio.3function liquidate(address _borrower) external45// Liquidate one trove, in Normal Mode.6function _liquidateNormalMode(7 IActivePool _activePool,8 IDefaultPool _defaultPool,9 address _borrower,10 uint _LUSDInStabPool11) internal returns (LiquidationValues memory singleLiquidation)1213// Liquidate one trove, in Recovery Mode.14function _liquidateRecoveryMode(15 IActivePool _activePool,16 IDefaultPool _defaultPool,17 address _borrower,18 uint _ICR,19 uint _LUSDInStabPool,20 uint _TCR,21 uint _price22) internal returns (LiquidationValues memory singleLiquidation)2324/*25 * Liquidate a sequence of troves. Closes a maximum number of26 * n under-collateralized Troves, starting from the one with27 * the lowest collateral ratio in the system, and moving upwards.28 */29function liquidateTroves(uint _n) external
It sends redemption fees to the LQTYStaking contract in the
redeemCollateral function:1function redeemCollateral(2 uint _LUSDamount,3 address _firstRedemptionHint,4 address _upperPartialRedemptionHint,5 address _lowerPartialRedemptionHint,6 uint _partialRedemptionHintNICR,7 uint _maxIterations,8 uint _maxFeePercentage9) external
TroveManager contract stores its data in
ContractsCache:1ContractsCache memory contractsCache = ContractsCache(2 activePool,3 defaultPool,4 lusdToken,5 lqtyStaking,6 sortedTroves,7 collSurplusPool,8 gasPoolAddress9);
And while redeeming the collateral it will use them for the following actions:
1// To confirm redeemer's balance is less than total LUSD supply2assert(3 contractsCache.lusdToken.balanceOf(msg.sender)4 <= totals.totalLUSDSupplyAtStart5);67// To add the borrower's coll and debt rewards earned8// from redistributions, to their Trove9_applyPendingRewards(10 contractsCache.activePool,11 contractsCache.defaultPool,12 currentBorrower13);1415// To send the ETH fee to the LQTY staking contract16contractsCache.activePool.sendETH(17 address(contractsCache.lqtyStaking),18 totals.ETHFee19);20contractsCache.lqtyStaking.increaseF_ETH(totals.ETHFee);
Get the DeFi Protocol Security Checklist
15 vulnerabilities every DeFi team should check before mainnet. Used by 40+ protocols.
No spam. Unsubscribe anytime.
Also contains the state of each Trove — i.e., a record of the Trove's collateral and debt. The status is defined in an enum, which is passed as a parameter to internal functions like
_closeTrove(address _borrower, Status closedStatus):1enum Status {2 nonExistent,3 active,4 closedByOwner,5 closedByLiquidation,6 closedByRedemption7}
TroveManager does not hold value (i.e. Ether / other tokens). TroveManager functions call in to the various Pools to tell them to move Ether/tokens between Pools, where necessary.
SortedTroves.sol
A doubly linked list that stores addresses of Trove owners, sorted by their individual collateralization ratio (ICR).
It inserts and re-inserts Troves at the correct position based on their ICR. The function used for this is
_insert:1function _insert(2 ITroveManager _troveManager,3 address _id,4 uint256 _NICR,5 address _prevId,6 address _nextId7) internal
Before entering the logic to store the addresses it goes through a series of requirements:
1// List must not be full2require(!isFull(), "SortedTroves: List is full");3// List must not already contain node4require(!contains(_id), "SortedTroves: List already contains the node");5// Node id must not be null6require(_id != address(0), "SortedTroves: Id cannot be zero");7// NICR must be non-zero8require(_NICR > 0, "SortedTroves: NICR must be positive");
Borrowing
Users can borrow by opening a Trove. After posting ETH as the underlying collateral, loan recipients receive an interest-free loan of LUSD, the protocol's native stablecoin.
How is the borrowing fee calculated?
The borrowing fee is added to the debt of the Trove and is given by a
baseRate. The fee rate is confined to a range between 0.5% and 5% and is multiplied by the amount of liquidity drawn by the borrower.Also, a 200 LUSD Liquidation Reserve charge will be applied as well, but returned to you upon repayment of debt.
For example: The borrowing fee stands at 0.5% and the borrower wants to receive 4,000 LUSD to their wallet. Being charged a borrowing fee of 20.00 LUSD, the borrower will incur a debt of 4,220 LUSD after the Liquidation Reserve and issuance fee are added.
BorrowerOperations.sol
Trove creation, ETH top-up / withdrawal, stablecoin issuance and repayment.
It also sends issuance fees to the LQTYStaking contract. BorrowerOperations functions call in to TroveManager, telling it to update Trove state, where necessary. BorrowerOperations functions also call in to the various Pools, telling them to move Ether/Tokens between Pools or between Pool and user, where necessary.
We can see in this enum which are its main functionalities:
1enum BorrowerOperation {2 openTrove,3 closeTrove,4 adjustTrove5}
The contract is storing the main relevant data in a struct:
1struct ContractsCache {2 ITroveManager troveManager;3 IActivePool activePool;4 ILUSDToken lusdToken;5}
openTrove()
1function openTrove(2 uint _maxFeePercentage,3 uint _LUSDAmount,4 address _upperHint,5 address _lowerHint6) external payable
There are two requirements that need to be met in order to start the borrowing operations:
1function _requireValidMaxFeePercentage(2 uint _maxFeePercentage,3 bool _isRecoveryMode4) internal pure {5 if (_isRecoveryMode) {6 require(7 _maxFeePercentage <= DECIMAL_PRECISION,8 "Max fee percentage must less than or equal to 100%"9 );10 } else {11 require(12 _maxFeePercentage >= BORROWING_FEE_FLOOR13 && _maxFeePercentage <= DECIMAL_PRECISION,14 "Max fee percentage must be between 0.5% and 100%"15 );16 }17}
and:
1function _requireTroveisNotActive(2 ITroveManager _troveManager,3 address _borrower4) internal view {5 uint status = _troveManager.getTroveStatus(_borrower);6 require(status != 1, "BorrowerOps: Trove is active");7}
Then it sets up some properties for this Trove:
1contractsCache.troveManager.setTroveStatus(msg.sender, 1);2contractsCache.troveManager.increaseTroveColl(msg.sender, msg.value);3contractsCache.troveManager.increaseTroveDebt(msg.sender, vars.compositeDebt);
"1" in this case is the
active state.Right after, it stores the Trove assigned to the borrower address:
1sortedTroves.insert(msg.sender, vars.NICR, _upperHint, _lowerHint);2vars.arrayIndex = contractsCache.troveManager.addTroveOwnerToArray(msg.sender);
Last but not least, there are two very important things happening — adding the ether provided as collateral from the user and returning to the user the newly minted LUSD tokens:
1// Move the ether to the Active Pool, and mint the LUSDAmount to the borrower2_activePoolAddColl(contractsCache.activePool, msg.value);3_withdrawLUSD(4 contractsCache.activePool,5 contractsCache.lusdToken,6 msg.sender,7 _LUSDAmount,8 vars.netDebt9);
And the second thing, storing the specific LUSD tokens in the Gas Pool:
1// Move the LUSD gas compensation to the Gas Pool2_withdrawLUSD(3 contractsCache.activePool,4 contractsCache.lusdToken,5 gasPoolAddress,6 LUSD_GAS_COMPENSATION,7 LUSD_GAS_COMPENSATION8);
closeTrove()
1function closeTrove() external
The main requirements are:
1_requireTroveisActive(troveManagerCached, msg.sender);2uint price = priceFeed.fetchPrice();3_requireNotInRecoveryMode(price);
The name of the requirement functions are very self-explanatory. And the price is fetched from the Oracle.
Now, let's split in three main actions that are happening here:
First, it handles the Trove. Meaning we close it and remove the stake:
1troveManagerCached.removeStake(msg.sender);2troveManagerCached.closeTrove(msg.sender);
Second, it burns the repaid LUSD from the user's balance and the gas compensation from the Gas Pool:
1_repayLUSD(activePoolCached, lusdTokenCached, msg.sender,2 debt.sub(LUSD_GAS_COMPENSATION));3_repayLUSD(activePoolCached, lusdTokenCached, gasPoolAddress,4 LUSD_GAS_COMPENSATION);
Third, it sends the ETH that was added as collateral back to the user:
1activePoolCached.sendETH(msg.sender, coll);
PriceFeed and Oracle
Liquity functions that require the most current ETH:USD price data fetch the price dynamically, as needed, via the core PriceFeed.sol contract using the Chainlink ETH:USD reference contract as its primary and Tellor's ETH:USD price feed as its secondary (fallback) data source.
PriceFeed is stateful, i.e. it records the last good price that may come from either of the two sources based on the contract's current state.
The fallback logic distinguishes 3 different failure modes for Chainlink and 2 failure modes for Tellor:
- Frozen (for both oracles): last price update more than 4 hours ago
- Broken (for both oracles): response call reverted, invalid timeStamp that is either 0 or in the future, or reported price is non-positive (Chainlink) or zero (Tellor). Chainlink is considered broken if either the response for the latest round or the response for the round before the latest fails one of these conditions.
- PriceChangeAboveMax (Chainlink only): higher than 50% deviation between two consecutive price updates
There is also a return condition
bothOraclesLiveAndUnbrokenAndSimilarPrice which is a function returning true if both oracles are live and not broken, and the percentual difference between the two reported prices is below 5%.The current PriceFeed.sol contract has an external
fetchPrice() function that is called by core Liquity functions which require a current ETH:USD price. fetchPrice() calls each oracle's proxy, asserts on the responses, and converts returned prices to 18 digits.Get in touch
At Zealynx, we specialize in DeFi protocol security audits — from lending and borrowing systems like Liquity to complex stablecoin mechanisms and liquidation logic. Whether you're building a CDP-based protocol or forking an existing one, our team is ready to help you ship secure code. Reach out to start the conversation.
Want to stay ahead with more in-depth analyses like this? Subscribe to our newsletter and ensure you don't miss out on future insights.
Get the DeFi Protocol Security Checklist
15 vulnerabilities every DeFi team should check before mainnet. Used by 40+ protocols.
No spam. Unsubscribe anytime.


