Ensure the security of your smart contracts

Modern DeFi Lending Protocols, how it's made: Fluid + Vault

Author: Sergey Boogerwooger, Victor Yurov
Security researchers at MixBytes
Intro
Fluid is a modern multi-level protocol, with the base layer, responsible for the main functions, and the secondary layers, that can implement various advanced DeFi mechanics. The key features of Fluid, highlighted in their docs, are the ability to utilize liquidity from multiple sources, protection from abrupt fund movements in the protocol, and underlying protocols that allow low liquidation penalties, high Loan-To-Value ratios, and gas efficiency.

Fluid's base "liquidity layer" for users' collateral-debt positions provides reentrancy protection, rate-limiting for markets, as well as oracles supply/borrow rates from oracles, and financial model management. The secondary "protocol layer" allows for the implementation of multiple protocols based on the liquidity layer. Today, there are three protocols on project's GitHub:

Lending protocol (contracts, docs)
Vault protocol (contracts, docs)
stETH protocol (contracts)

We will mainly concentrate on the Vault protocol (as it's called in the code), because the name of the article contains "modern", while the first ("Lending") protocol is a "base" lending with ERC-4626 shares in a vault, providing all base lending mechanics with the rate-limiting features of the liquidity layer. The "stETH" appears to be a specialized protocol for staked ETH, but the most interesting mechanics are implemented in the Vault protocol. We cannot avoid some references to other protocols as all protocols use the same base layer, but will mainly concentrate on Vault.

Let's start.
Higher-level design
The main liquidity layer is presented in the liquidity part and consists of two main modules:

  • adminModule, responsible for governance actions, setting model parameters, exchange prices and token configs
  • userModule, responsible for the basic operations, accompanying supply/withdraw and borrow/payback operations in protocols

The underlying protocols, like Vault or Lending, use these basic operations in the liquidity layer after implementing their own logic. For example, Vault employs NFTs to store users' collateral-debt positions, which are distributed over the range of price ticks (similar to Uniswap V3 and CrvUSD), and, after all external Vault logic, the base operations at the liquidity layer are applied (for example here or here).

This design somehow looks like an Euler V2 design (described in our previous article) offering some types of protection at the base layer, but at the same time allowing to "wrap" this base logic in many different DeFi scenarios.

Let's dive a little deeper.
Core
Governance and model management
First, let's focus on the adminModule, containing governance functions. These functions in Fluid allow to:

  • configure and collect protocol revenue
  • set borrow/supply rates, according to the currently used model
  • set exchange prices and rates for different tokens
  • some other functions, not relevant to our article

Of particular importance is the ability to set exchange prices and rates. In Fluid, these are managed through the _exchangePricesAndConfig[token_] mapping with per-token configs described here. Notably, a lot of Fluid values, including configurations, are stored in 256-bit fields, so the size of the Fluid state is very compact compared to other protocols.
Main operations
Fluid protocols, including Vault, operate on two distinct layers. The base "liquidity" layer, residing in the userModule, contains two basic functions: _supplyOrWithdraw() and _borrowOrPayback(). These functions integrate into the universal operate() function that can be found in multiple places in each of Fluid protocols (for example here (in Lending), here (in stETH), or here (in Vault)).

This operate() function performs operations that are standard across all protocols, managing both aspects of liquidity: supply and borrowing. Its purpose is multifaceted: updating the protocol's reserves, protecting the market from drastic fluctuations by enforcing limits, and acting as a "safety wrapper" for any operation conducted by users. For example, within the _borrowOrPayback() function, we can find both the pre- and post- calculation of limits. This ensures that any sudden shifts in the protocol's reserves, potentially triggered by external protocol logic, are restricted by these limits.

The operate() function accepts signed supplyAmount_ and borrowAmount_ values, allowing to perform supply/withdraw and borrow/repay operations using a single function. Both amounts are measured in token_ units and operate with a single token. All the collateral-debt positions in the protocol are stored "per-token". Each position maintains the limits using the units of the current token (without any conversions) or values converted to a raw USD value (collateral and debt in the same measurement units - USD).

A supply/payback situation, when a user needs to transfer tokens to the protocol, is handled via the liquidityCallback() provided by the user (the reentrancy protection here is crucial).

Next - the extraction of two prices: the supply price and borrow price are here from exchangePricesAndConfig.

Moving on, the workflow diverges into two branches: one for supply/withdraw and the other for borrow/payback. After these operations, _totalAmounts[token_] is updated. Operating with a single token results in the update of only that particular token_'s balance within the protocol.

Subsequently, the protocol updates exchange prices, utilization, and ratios. The update occurs if the changes are substantial or if the last update was deemed too outdated (threshold values for utilization and price updates are loaded here). For minor changes, only the supplyExchangePrice and borrowExchangePrice are updated (here).

In the final step, the required amounts of tokens are sent to the user as necessary.
Supply and borrow
In the _supplyOrWithdraw() function within the 'supply/withdraw' branch, the protocol supports two types of supply: one that accrues interest and another that is an interest-free model.

Both are handled by two separate branches of updates. The key data structure, _userSupplyData, maps user addresses to a uint256 value for each supply token used. This value contains all relevant user supply information, including amounts, flags, and timestamps, packed as bit areas within the single uint256 value.

As mentioned above, a supplier cannot withdraw all of their supply tokens. When a user supplies tokens, new withdrawal limits are calculated with two functions: calcWithdrawalLimitBeforeOperate() and calcWithdrawalLimitAfterOperate(), restricting the final withdrawal limit for the user.

In the _borrowOrPayback() function, which addresses the 'borrow/payback' aspect, the user's borrow limit is calculated twice: before and after the operation. While the user's borrowing data are contained in the _userBorrowData struct, which is packed as a uint256 value.

As you can see, there are no liquidations in the userModule/ of the liquidity layer. The management of liquidatable positions is handled by the underlying protocols. The liquidity layer is solely responsible for tracking debt, supply, and oracle prices.

Now, let's delve deeper into the specific protocol - Vault.
The Vault protocol
The Vault protocol is built upon the Fulid liquidity layer. Vault implements a highly efficient liquidation mechanism that prevents individual position liquidations by consolidating them into groups (ticks), significantly reducing the risk of bad debts in volatile market conditions. Additionally, the protocol incorporates a bad debt absorption feature, ensuring that liquidations are not unprofitable for the liquidator.

Users' collateral/debt positions in Vault are stored as NFTs, containing position data in the positionData struct, where the most important value is the position's tick.

The key idea of the Vault protocol is inspired by Uniswap V3 concentrated liquidity and price ticks (you can read more here). In Uniswap V3, ticks shift in response to changes in the exchange ratio between two tokens (price). In Fluid's Vault, ticks represent the movement of the debt-collateral ratio in users' positions. Ticks in the Vault are defined as:

where t is a tick number

Users start at some particular tick, representing the current collateral/debt ratio, and their positions "belong" to this tick. When the price of the collateral goes up, it's ok, and new users simply "settle" at higher and higher ticks. But when the USD price of collateral goes down, the ratio of the position's tick becomes "liquidatable". It's demonstrated at the picture from the Vault whitepaper (which is highly recommended to read).
The price of the collateral goes down, decreasing the collateral/debt ratio. Positions with a higher collateral-to-debt ratio are subject to liquidation, using sufficient amounts to move them to "safer" ticks.

An additional significant mechanism implemented in Fluid is "branches". Please, look at the picture: (from the whitepaper):
A branch is a process that occurs within ranges where the collateral price decreases and liquidations become necessary. When a branch is active, the liquidation of positions "belonging" to this branch can be performed. Branches may be merged with others; for example, Branch 2 is merged into Branch 1 when its base level is reached. This process is better described in the whitepaper, while our goal is to review the implementation. Therefore, let's examine the branchData struct. The most critical elements are the minimaTick - the minimal tick for this branch, making this branch obsolete or merged when reached - and the branch debt. Branches facilitate the monotonic and sequential elimination of the debt from unhealthy ticks.

For all operations the Vault protocol uses a single entry point: the operate() function. Although it shares its name with the operate() function at the liquidity layer, it operates differently by interacting with an NFT that represents a user's position. This operation is "universal" for deposit/withdraw and borrow/payback, so, all the changes during the operations are tracked in the special operation structure OperateMemoryVars, which accumulates information about the previous and next ticks of the user's position. As liquidations in Vault don't access individual users' positions data directly, this data must be updated whenever the position is accessed. For instance, if a position's tick becomes subject to liquidation, it needs to be updated to the 'nearest available' tick at the end of the operation.

The supply and borrowing operations in the operate() function write new colRaw and debtRaw fields into the operation variables (here and here), calculating the raw values of the position debt/collateral using borrowExPrice and supplyExPrice. The next step, which demonstrates work with ticks, is the part where the debt is being added to some tick, using the _addDebtToTickWrite() function. The conversion from the ratio to the tick number requires the calculation of a logarithmic function. This calculation is done using precalculated values in tickMath.sol, similarly to Uniswap V3.

The mechanics of assigning different ratios to different ticks using "not-exact" amounts of tokens lead to the occurrence of dust in the protocol, so, in Fluid, a part of logic requires handling these "dust" values. For example, the same _addDebtToTickWrite() function, described above, also returns a rawDust_ value that must be later saved in the user's position to keep all values in sync. Finally, the dust is absorbed by the protocol here and then can be processed using the admin function absorbDustDebt().

But, of course, the most interesting part of the Vault protocol review is the liquidate() function, allowing to liquidate positions of multiple users at once. First, the liquidation updates borrow/supply prices, and this represents another important connection with the base liquidity layer, providing supply/borrowing exchange prices for multiple protocols (here).

After some oracle checks (which will be described below), the Vault absorbs the debt in the ticks above the tick being liquidated. Additionally, the liquidate() function can liquidate the bad debt from the protocol if the absorb_ flag is set.

The core of liquidation is the loop over the ticks, which accumulates debt and collateral on each iteration. Each iteration "belongs" to a current liquidation branch, "tracking" the ticks being liquidated. A refTick is determined here, which is later used to move the liquidation tick forward (here and here with consideration for some edge cases.

The calculation of the resulting debt/collateral in a single tick with the already known ratio is outlined here. After that the current tick is advanced (as shown above), and then the information for the current branch is updated (here and here).

There was no access to any of the users' individual positions during the liquidation process! The code operates solely with ticks and branches. This is a key feature of the Vault protocol: the protocol rebalances the collateral/debt ratio seamlessly, for all users' positions simultaneously, without "touching" them.

From algorithmic perspective, this mechanism acts similarly to the Uniswap V3 fees mechanics, where the liquidity provision and fees accumulation are managed using only ticks' internal data and global accumulators (described in the article), without direct interaction with the liquidity providers' positions. However, it's important to distinguish between the protocols. In Fluid, ticks manage collateral/debt ratios, not prices, and they exclude global fee accumulators. The mechanics of the ticks also differ.

It seems like the idea of splitting the ranges of prices, ratios, amounts (and we don't know what else yet) into discrete ticks makes sense in DeFi, allowing to avoid "per position" interactions with DeFi protocols.

This design is exceptionally gas-efficient for all users: borrowers/suppliers and traders/liquidators. Liquidations work seamlessly, with additional restrictions on price and volumes movement, "enveloping" the full spectrum of users' positions by liquidation "waves". True to its name, the protocol is indeed "Fluid" :)
Oracles
Oracles are always a critically important part of any lending protocol. Within the Fluid ecosystem, oracles operate on two distinct levels. The base liquidity layers provide exchange prices for all tokens used by the underlying protocols. These protocols (such as Vault) then apply an additional layer of protection based on their unique logic.

Fluid oracles have many different implementations, which provide access to the getExchangeRate***() functions at the next layer. These implementations might, for example, interact with Uniswap V3 TWAPs and apply rate limits, or employ Chainlink/Redstone providers and use them as fallback oracles. Such implementations can be combined in the oracles like UniV3CheckCLRSOracle.sol, which utilizes Uniswap V3 TWAP prices and compares them with those from Chainlink/Redstone. This design ensures a high level of security, making it exceedingly difficult to manipulate multiple oracle sources, especially with the added rate limits. At the same time, lagging and rate limited price oracles make the protocol an attractive target for marginal trading strategies during periods of high market turbulence, which can be good or bad, depending on the conditions.
Implementation details
The code of Fluid is full of constructions like:
It's essentially an extraction of values from uint256 because Fluid aims to efficiently pack numerous values into the 256-bit slots. The method of saving data in a single value looks like this. Additionally, the Fluid codebase actively reuses the same variables like here, which complicates readability but reduces gas consumption. Fluid also predominantly uses "uint256" as a packed struct, nearly eliminating the use of structs. While Solidity structs typically pack values into one storage slot, Fluid's approach may be due to the use of many non-standard value sizes, such as 19 bits, 50 bits, and 1 bit (examples here or here). This approach has its advantages (gas efficiency), but at the same time requires strict control over overflows in non-standard values. It increases the amount of code and reduces code readability.

Fluid enables operations with native tokens (like ETH), leading to numerous branches like this one in the code.

Fluid employs mathematical approaches similar to those used in Uniswap V3, such as finding the most significant bit here or using precomputed logarithms to determine the tick number from a given ratio. Values are often represented as a BigNumber, comprising exponent and coefficient parts (the conversion can be found here).

The code of the Fluid protocol, along with its Vault feature, boasts many intriguing implementation details. Regrettably, due to the limited space in the article, it's not possible to delve into each one in detail.
Conclusion
Fluid is a project with a two-layer structure. The first base liquidity layer provides the basic framework, storing debt and collateral values, as well as the overall state of the protocol and oracle prices. It also includes safeguards like global rate limits to protect the market from abrupt movements. The second layer is presented by multiple protocols designed for different functions such as lending, decentralized exchanges, vaults, and more.

We have reviewed the Vault protocol, whose main idea is the usage of Uniswap V3-like ticks, but applied to the collateral/debt ratio. This innovative design groups users' positions with the same collateral/debt ratio, enabling seamless liquidation with minimal penalties. Liquidators, acting as traders, are afforded a flexible means to exchange collateral tokens for borrowed ones, moving users' positions towards a healthier status. The liquidation operation doesn't directly interact with users' positions but affects the entire scope of ticks with an unhealthy collateral/debt ratio. This effect is realized through the use of "branches" - accumulators that track the liquidation process when the collateral price decreases, which can be merged into one another, making the process more "fluid".

Implementing these mechanics is not straightforward, and our audit of this protocol was indeed challenging. However, from many perspectives, Fluid + Vault stands out as a truly original project that combines various interesting ideas, advancing decentralized finances towards increasingly efficient design patterns.

Let's look forward to the innovative ideas that the future holds…
Who is MixBytes?
MixBytes is a team of expert blockchain auditors and security researchers specializing in providing comprehensive smart contract audits and technical advisory services for EVM-compatible and Substrate-based projects. Join us on Twitter to stay up-to-date with the latest industry trends and insights.
Disclaimer
The information contained in this Website is for educational and informational purposes only and shall not be understood or construed as financial or investment advice.
Other posts