Modern DEXes,
how they're made: Fluid DEX

Author: Sergey Boogerwooger, Artem Petrov
Security researchers at MixBytes
Introduction
We previously reviewed a protocol built using the Liquidity Layer of the Fluid project, known as Fluid Vault lending protocol. Fluid DEX is another protocol utilizing the same foundational layer, showcasing Fluid's multi-layer approach to developing DeFi projects. The primary feature of Fluid DEX is its capacity to use collateral/debt assets from the lending layer to provide liquidity for swaps in the DEX. This enables the use of swap fees to enhance supply and decrease debt. To achieve these goals Fluid DEX employs "Smart Debt" and "Smart Collateral" concepts, which work with pairs of assets rather than single assets. A high-level overview can be found here.

As mentioned, Fluid's base Liquidity layer offers accounting, reentrancy protection, rate limiting for markets, and other foundational mechanics for the underlying protocol. Fluid DEX acts as the secondary "protocol layer", "settling" its assets using the base liquidity layer primitives. We've already seen that the mechanics of tracking user debt/supply can be used in DEXes to support multicalls (examples: Uniswap V4 and Balancer V3), allowing users to execute multiple operations with different pools, perform swaps, and manage liquidity in a single multicall. Throughout these operations, the protocol simply tracks accumulated in/out debt for all used tokens, permitting the necessity to provide all needed tokens only at the end of the multicall, combining all operations. This approach significantly reduces gas costs for complex trading operations and enhances protocol flexibility.
Fluid DEX employs a similar method; however, the base collateral/debt layer is more complex and acts as the "accountant," whereas the second, DEX layer, serves as the "operator."

Let's examine the technical design of this protocol.
High-Level Design
We will skip some parts related to the base liquidity layer of Fluid DEX, as they were covered in the previous article about Fluid Vault. The core mechanic of Fluid DEX is "DEX-on-lending" (where "lending" is implemented in the Liquidity layer and DEX on the "protocol" layer). In this setup, single Fluid DEX instance acts as a singular lender/borrower interfacing with the lending platform. Swap operations in Fluid DEX facilitate users' asset settlements within the liquidity layer, combining deposit/withdrawal/borrow/payback functions to manage token "in/out" operations. The liquidity providers of the lending protocol are simultaneously liquidity providers for the DEX, while DEX swaps rebalance collateral/debt asset distributions.

Fluid DEX, as a lending platform, utilizes "smart collateral" and "smart debt," implying that instead of a single token, pairs of tokens can serve as collateral or debt, such as wstETH-ETH or WBTC-cbBTC. Therefore, users supply or borrow the token pairs. Even if a user borrows 100 USDT using Smart Debt USDT/USDC, the debt is treated as a position with 50 USDT/50 USDC. This distribution can be changed by the trades on the DEX involving USDT or USDC. Each time a user swaps assets using Fluid DEX, the distribution of tokens in the "smart" lending supply/borrow positions adjusts. Swap fees, acquired from the swaps, alter collateral or debt balances, either increasing supply APR or reducing the borrow rate. This is the main concept of Fluid DEX (a high-level explanation is available here).

To manage smart collateral and debt, swap operations in Fluid DEX are executed using two mirrored pools: one with "supply token1/borrow token2" and the second with "supply token2/borrow token1". The swap process involves operations like "supply(collateral) & payback(debt)" for "in" tokens, and "withdraw(collateral) & borrow(debt)" for "out" tokens within these two pools at the base liquidity layer:

Thus, each separate "dex" in Fluid has two underlying lending pools on the liquidity layer side, and this "dex" operates as a user of these two lending pools. The DEX layer, in this framework, calculates the amounts for supply/borrow operations according to the Uniswap V2-like invariant and rebalances assets between pools to maintain consistent token1->token2 and token2->token1 prices.

Let's delve deeper into the code.
Core
The Fluid DEX codebase is the same as Fluid Vault: https://github.com/Instadapp/fluid-contracts-public/tree/main/contracts, with the DEX protocol code residing in the protocols/dex directory.

The constructor of the main contract main.sol involves setting the addresses of specialized contracts:

  • colOperations – manages "deposit/withdraw" operations with collateral.
  • debtOperations – manages "borrow/payback" operations with debt.
  • perfectOperationsAndSwapOut – handles "perfect" deposit/withdraw/borrow/payback operations, operating with shares rather than token amounts (keeping pools balanced). Additionally, this contract manages "SwapOut" logic.
  • shift – handles center price, thresholds and ranges adjustments.
  • admin – administrative functions.

As mentioned, Fluid DEX is a "DEX-on-lending," which includes "lending" operations (deposit, withdraw, borrow, payback) that simultaneously serve as liquidity provisioning for swaps. This is why traditional lending operations are also present in the "DEX" protocol. The code is divided into three parts: "collateral," "debt," and "perfect," grouping similar operations. Collateral and debt operations in Fluid DEX use rebalancing in two lending pools, while the "perfect" operations function without rebalancing and are, therefore, separated into a distinct module.
Let's proceed to the swaps.
Swaps
The core functions of Fluid DEX are the _swapIn() in main.sol and _swapOut() in perfectOperationsAndSwapOut.sol, which are the most complex functions in the protocol. Let's start from the _swapIn().

Fluid DEX restricts operations that change the swap price by more than 5% (another "fluid" part), performing the _priceDiffCheck() in the last part of the swap operation (in the _updateOracle() function, which we will discuss later).

The price model of Fluid DEX is also "fluid," as all price movements(even governance changes of ranges and thresholds) are calculated with respect to previous price changes and performed gradually. Price values and ranges begin a continuous movement towards new value when range limits are reached. This requires tracking previous prices, shifting time, status of price shift, and other variables, controlling "dynamic" behaviour of pools. The state of the dex, containing this data, is stored in two main variables:

  • dexVariables: Here, the focus is on two previous swap prices of the pool and the time difference between them, the center price (to be described below), and the time of the last interaction.
  • dexVariables2: Contains flags (smart collateral/debt enabled), information about fees, and a pack of percentages, determining the range "around" the center price (similar to lower-upper price ranges for the current tick in a concentrated liquidity setting). Another important parameter is the shifting time, determining the rate of price movement (to be discussed below). The next pack of parameters are related to the restrictions on the center price (can be fetched externally, has max/min restrictions, restricted by utilization ratio, etc.). Another notable flag is the pause flag, which disables all "imbalanced" operations; only "perfect" operations can be performed (those that don't change the relative distribution of tokens in the pool).
The swap process parameters are stored temporarily in the in-memory SwapInMemory struct.

The first stop in the swap process is the pex_ struct, which holds exchange prices for the protocol. These are calculated by the _getPricesAndExchangePrices() function, the real "core" of Fluid DEX's swap (to be discussed below). Currently, it is important to note that there are separate prices for the supply/borrow of token1/token2.

As previously mentioned, operations in Fluid DEX involve two swaps: in "collateral" and "debt" pools. Therefore, the next step is preparing reserves for both parts of the swap. The first part is for collateral, and the second is for debt (the "imaginary" reserves also will be discussed below).
The next section deals with limiting operation amounts in the pool. We check if the "in" amount being swapped will not significantly affect pool reserves and price. There will be a _priceDiffCheck() at the end of the swap, but Fluid prefers to perform this check beforehand.

Then begins the section calculating "in" token amounts and their distribution between pools (in one pool, we perform a "deposit", in another - "payback"). We calculate swap amounts in the _swapRoutingIn() function, accepting the "in" amount t along with initial and in/out "imaginary" reserves for both collateral and debt. The result of this function provides a solution for the system of equations, while the result a determines which part of the swap goes to collateral, debt, or both pools. This solution results in these three branches, indicating which amounts go to "deposit" and which to "payback".

Next, we need to calculate "out" amounts for "withdraw" in one pool and "borrow" in another. Additionally, we need to check if the required amounts are available. This is done in the "_amountOutCol" and "_amountOutDebt" sections.

Next, we decide which pool will be used to determine the swap price: collateral or debt. We choose the one having a larger swap amount as the price source, but it is important to remember that, at the end of the swap, the final price of the given asset in both pools will be the same.
After converting token balances to normal amounts and setting the callback data (if transferring tokens is made via callback), there are two important operations with the base liquidity layer. The liquidity layer of Fluid (common for all protocols on Fluid) uses a single operate() function for all actions: supply/withdraw/borrow/payback, determined by two signed parameters: supplyAmount_ and borrowAmount_ (negative values of these parameters result in reverse operations). The first operation is:

LIQUIDITY.operate(..., +supply_amount, -payback_amount, ...);

while the second is:

LIQUIDITY.operate(..., -withdraw_amount, +borrowAmount, ...);

These operations direct all "in" tokens to supply collateral and payback debt, while the "out" tokens allow withdrawing collateral and borrowing debt on the protocol's liquidity ("lending") layer.

Next is a call to an additional hook that can stop the swap if collateral or debt pools require liquidation. We have a lending position for the Fluid DEX on the lending side, and this position can become "unhealthy," requiring liquidation. In such cases, any swaps should be halted before liquidation occurs. The "health" status is checked by this hook, whose interface and description can be found here. However, the combination of a DEX, careful selection of smart debt/collateral token pairs, and rebalancing of assets in these pools according to external market prices makes this situation unlikely.

The next part of the swap includes checking the utilization (total_debt/total_collateral ratio) in the liquidity layer. The function _utilizationVerify() checks if final utilization is not above the threshold set in the liquidity layer (an example of utilization usage in the liquidity layer's operate() function is here).

The final part of the swap involves calling _updateOracle().

The swapOut() function in the perfectOperationsAndSwapOut.sol module performs the same operations as swapIn(). The code is nearly identical, except for the calculation of out tokens with the _swapRoutingOut() function and the different order of withdraw/deposit/borrow/payback amounts. The same restrictions, utilization checks, and the _updateOracle() call at the end of the swap are applied.
Swap Prices
Fluid DEX has a very intersting dynamic scheme for calculating swap prices. The volatility of prices between assets in Fluid DEX is minimal because both the smart debt and smart collateral utilize assets pegged to the same values (e.g., USDC/USDT, ETH/stETH, WBTC/cbBTC, etc.). For such assets, it's beneficial to have "concentrated" liquidity that operates within a narrow price range. Meanwhile, swap price calculations are conducted using the constant product formula x∗y=k, similar to Uniswap V2. These properties have led Fluid DEX to adopt a scheme with dynamically changing price ranges and thresholds, yet functioning with the traditional constant product model.

During each operation (e.g., swap), we calculate a centralPrice, which in some cases can be retrieved from an external source (like here), such as for pairs like wstETH/ETH where the price of wstETH can be fetched from an external contract.

The central price determines "upper range" and "lower range" prices, operating like a Uniswap V3 pool where all real liquidity is concentrated within this price range. Additionally, there are also "imaginary" reserves outside this range. Thus, when a Uniswap V2-like invariant is needed, we take the upper and lower prices "around" the center price and extrapolate the invariant curve using these two points ("upper" and "lower"). Then, "outside" reserves are calculated:

The resulting sum of real and "outside" reserves is termed "imaginary reserves," and they are used to calculate a target invariant x∗y=k and swap amounts (as seen in the _swapRoutingIn() and _swapRoutingOut() functions), which use imaginary reserves instead of real. Additionally, price changes are also restricted by "hard" threshold values to prevent abrupt changes in swap prices.

The movement of the center price, which also determines the "upper" and "lower" ranges defining imaginary reserves for swap invariants, is carried out gradually. The new centerPrice_ begins to move towards either the upper or lower range, depending on time diffs.
Another place to mention is the admin functions updateRangePercent() and updateThresholdPercent(), updating relative upper and lower price ranges and thresholds. This changes also are not made instantly, each starts a continuous shifting toward the target values (this code for ranges, and this code for thresholds). In both functions we see the values movement based on time diff and statuses "shift is ongoing" or "shift is done".
These "fluid" mechanics allow Fluid DEX to avoid abrupt operations with price ranges and prevent instant liquidity movement between different "price buckets".
Oracles
Fluid DEX provides TWAP prices to external services using the oraclePrice() function, demonstrating how the protocol stores prices. This method is uncommon in DeFi - instead of storing previous prices directly, Fluid DEX stores only percentage changes between prices. For example: [currentPrice] -> [-0.12%] -> [+0.13%] -> [+0.07%] -> ... . This approach allows the return of TWAPs for different time ranges using a single loop over small-sized price diffs. The oraclePrice() function accepts an array of time intervals as a parameter and returns an Oracle[] array with TWAPs for these intervals. These TWAPs contain average prices and the highest and lowest prices for the given intervals.

oraclePrice() starts with loading of the last price (the last to last price is loaded in the second iteration in the loop). Then starts the main loop, iterating over oracle slots, and calculating the next price using the percentDiff_ difference.

Updates to these slots with percentDiff's and the last price are performed by the _updateOracle() function, encountered at the end of each swap.

A crucial part of this function is restricting price changes using the _priceDiffCheck() function, limiting price changes to no more than 5%, seen in all logical branches in _updateOracle() (e.g., here, here, and here). Any swap in Fluid DEX cannot move the swap price by more than 5%, making this protocol a reliable price source for other protocols and providing good protection against price oracle manipulation attacks, although it may respond more slowly to rapid and significant price changes.

The _updateOracle() function avoids extra updates when it's not the first swap in the block (we only update the last price here) or when the oracle is not active. The normal flow includes calculating the percentDiff here, selecting the next "free" slot (using oracle slots mapping data in dexVariables), and storing the signed percentDiff (this block of code). There is a corner case with oracle "slots," as the time difference between slots is stored using 9 bits, so a maximum value of 511 seconds can be stored (approximately 8.5 minutes). If the swap occurs later (intervals exceed 511 seconds), two oracle slots are used to store the price diff, allowing a constant amount of price slots even if the intervals between swaps are large (in this case, we simply iterate over a smaller number of previous prices).
Liquidity Providing
Since the Fluid DEX is a "DEX-on-lending" protocol, providing liquidity in this system essentially involves "lending" actions: depositing and paying back in two pools. The token in the first pool serves as collateral, while in the second, it is used as debt. These operations in Fluid DEX are divided into two modules: colOperations.sol and debtOperations.sol, which are responsible for collateral and debt operations, respectively.

Working with collateral and debt in a system with "composite" Smart Debt and Smart Collateral is only feasible using shares, as the distribution of underlying tokens can vary with each swap performed in the DEX. Additionally, there are "perfect" and "non-perfect" operations. "Perfect" operations do not change the token distribution, relative ratios, and thus, do not require additional rebalancing. These operations are part of the perfectOperationsAndSwapOut.sol module (the _swapOut() function is present in this module simply because it's too large to fit into one main.sol module).

All "perfect" functions: depositPerfect(), withdrawPerfect(), borrowPerfect(), paybackPerfect() operate only with share amounts in input parameters, and add or remove liquidity from pools in proportions that do not alter the distribution of tokens. Additional parameters for these functions include "min/max" amounts of provided/received tokens as slippage protection.

These functions are relatively straightforward, and the main consideration is calculating and applying borrow and withdrawal limits (example here, or here) for operations common across all Fluid protocols, not just DEX. Fluid restricts operations on the liquidity layer by applying limits that depend on the time elapsed. These limits protect the protocol from large-scale liquidity manipulations performed sequentially in a short period, a common scenario in DeFi hacks.

Now let's explore the non-perfect operations. Collateral operations include: deposit(), withdraw(), and withdrawPerfectInOneToken(). Debt operations are: borrow(), payback(), and paybackPerfectInOneToken(). We will not delve into each function, as they are designed similarly. After completing the calculations, they rely on two core functions: _depositOrPaybackInLiquidity() for "in" operations or LIQUIDITY.operate() for "out" operations. All these functions include maxSharesAmt/minSharesAmt parameters for slippage protection.

However, there is a crucial distinction in all the "non-perfect" functions, explaining why "perfect" functions like paybackPerfectInOneToken() are not included in the "perfect" section. All these functions call a special _arbitrage() function at the end. This function ensures that collateral and debt pools remain balanced when token additions/removals affect the distribution. For instance, if we have two pools: USDC(col)/USDT(debt) and USDT(col)/USDC(debt), adding USDC to one pool will lead to different USDC<->USDT swap rates between the pools. Thus, part of the USDC must be converted to USDT, updating the balances to maintain consistent exchange rates across both pools. The _arbitrage() function begins by retrieving pool reserves if they are associated with Smart Debt or Smart Collateral. Arbitrage is needed only if both Smart Collateral and Smart Debt are enables, otherwise, it simply updates the price and returns.

Then, arbitrage procedure calls the _swapRoutingIn() function with zero "in" tokens. In case where pools are unbalanced, this function returns the portion of tokens that should be sent to the collateral or debt pool and specifies which pool's reserves need adjustment to equalize prices. In one case, we operate on the collateral pool, performing a deposit of token0 + withdrawal of token1 (here). In the second case, it involves the debt pool with a payback of token0 + deposit of token1 (here). The final part is similar to swaps - verifying minimal price change and updating the oracle.
Implementation Details
We have previously discussed some implementation details of Fluid in our earlier article about the Fluid Vault protocol. Fluid DEX introduces a few more places to mention.

A notable aspect is the implementation of admin functions, which can be called in the core/main.sol module, but the call is forwarded to the admin functions implementation using the fallback() function (similar to certain proxies). The fallback function first checks if the user is authorized, then sets the non-reentrant flag, and then performs the delegatecall to the ADMIN_IMPLEMENTATION using the _spell() function. Admin functions are protected by the _onlyDelegateCall modifier, making them accessible only via the main module. The admin module includes numerous functions related to setting various limits, pausing swaps, arbitraging between pools, etc.

Another area of interest is the procedure for deploying a new DEX here. It uses the simple CREATE opcode, meaning the factory's nonce is used to calculate the address of the deployed contract. Thus, the "address" of the deployed DEX can be stored not as an address (20 bytes), but as a simple dexId (equal to the nonce at deployment time). The resulting address can be calculated from the dexId by hashing it with the deployer's address. This technique facilitates storing IDs of multiple deployed contracts instead of addresses.

The implementation of Fluid DEX includes numerous instances where it is necessary to solve systems of equations, for example in the _swapRoutingIn() or _swapRoutingOut() functions. The solutions to these equations are presented in the comments, providing a comprehensive understanding of how target values are determined.
Conclusion
In Curve's LLamaLend, we witnessed a "lending-on-DEX" concept, whereas Fluid DEX is a "DEX-on-lending" system. It uses a composite collateral and debt system, consisting of two tokens, and employs DEX swaps to rebalance collateral and debt pools. Fluid DEX applies dynamic limitations on operational volume and price movements, protecting the protocol from abrupt attacks. This architecture allows for an increase in supply APR, reducing borrowing rates (by adding swap fees to collateral and subtracting them from debt), and automatically rebalancing debt and collateral for healthier user positions simultaneously.

These features are challenging to implement, and the protocol's code is complex as it involves interactions with multiple pools instead of just one, arbitrage between them, and calculations and applications of limits at various execution layers.

Fluid DEX is undeniably a significant project in the DeFi space, clearly demonstrating that future protocols will combine both DEX and lending mechanics, and that the mathematics behind algorithmic finance is becoming ever-intriguing. Modern DeFi is advancing rapidly, and centralized banks and exchanges are increasingly lagging with their obsolete and inflexible algorithms. Godspeed to all of us!

See you in our next articles.
  • 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 X 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