Modern DEXes,
how they're made: Curve StableSwapNG

Author: Sergey Boogerwooger, Pavel Morozov
Security researchers at MixBytes
Introduction
Curve's StableSwapNG is a decentralized exchange (DEX) project created for the efficient trading of stable assets or those pegged to the same value (such as ETH and stETH). It encourages liquidity providers from various protocols to participate, enhancing market liquidity and ensuring that target assets are easily tradable for platform users.

StableSwapNG allows for the deployment of swap pools for different tokens, with each pool capable of containing up to eight distinct tokens. This implementation employs a multi-dimensional invariant hypersurface, enabling curves in 3-dimensional, 4-dimensional, or even higher dimensions, compared to the traditional 2-dimensional invariant curve seen in Uniswap.

The StableSwap invariant and its corresponding automated market maker (AMM) design are thoroughly described in StableSwap's whitepaper and other sources. Instead of delving into the complex math behind swapping, this discussion will focus on the implementation details of StableSwapNG. Let's begin.
Core
We will explore the stableswap-ng repository, starting with the core component: the pool factory, CurveStableSwapFactoryNG.vy. This contract deploys two primary types of swap pools: PlainPool and MetaPool, along with a special Gauge contract for distributing additional rewards.

We will review the functions deploy_plain_pool() and deploy_metapool(), which are used to create these primary pool types. The "plain pool" is intended for "standard" token exchanges, while the "meta pool" can exchange LP tokens of other pools. In a meta pool, there is a "base" pool, where the LP token is the first token listed.

Common pool deployment parameters include lists of coins (up to eight), their decimals, types, rate oracles, and method signatures for calling these oracles. Other crucial parameters are the swap fee and the amplification factor for the invariant curve. The coin types can include:
  • Regular ERC20 tokens
  • Tokens with rate oracles (like wstETH)
  • Rebasing tokens (like stETH)
  • ERC4626 share tokens
These types of tokens may require special handling in certain cases (e.g., here). As seen in the Balancer V3 article, managing non-standard tokens in DEXes is always a complex endeavor.

A pivotal parameter to highlight is the _implementation_idx. Curve StableSwapNG allows for deploying various pool types that must be able to upgrade their logic or apply fixes. The Factory contract is responsible for setting new addresses for the PlainPool, MetaPool, Gauge, Math, and Views implementation contracts used for deploying new logic. Hence, the specific implementation is selected by its index from self.pool_implementations and self.metapool_implementations.

The factory’s deployment process involves "registering" new pools in self.pool_data, configuring their details, and adding coin addresses to the self.markets mapping. This mapping allows identifying pools for a given coin pair. To search for pools to facilitate swaps between coin1 and coin2, the key is defined simply as uint256(coin1) XOR uint256(coin2). The use of XOR ensures the same key is generated regardless of the order of the coin addresses. An example can be found here.

Another pool deployment function in the Factory is add_base_pool(), accessible only by the Factory's admin. This pool serves as the base pool for the MetaPool.

An intriguing parameter to discuss is _ma_exp_time, which defines the time window for the pool’s moving average price oracle. Curve permits the configuration of this parameter because different sets of tokens require varying "reaction times" from the price oracle. Some tokens, especially more volatile assets, need a quicker response, increasing the oracle's vulnerability to price manipulations. Conversely, other tokens can accommodate a larger sliding time window, making the price oracle far more resistant to manipulation. We will revisit this parameter later.

The deployment of new pools is executed using Vyper's built-in function create_from_blueprint(). It takes the address of the contract implementation and deploys it with the specified constructor arguments. In StableSwapNG, this function doesn’t use a salt parameter, meaning all deployments are executed using the CREATE opcode (unlike Uniswap, where all pool addresses are deterministically derived from token addresses).

In Curve's StableSwapNG, there are no configurable swap/liquidity hooks (unlike in Balancer V3 or Uniswap v4). The logic is fixed, and only the implementations of pools and the Gauge can be modified through governance.
Plain Pool
We will start with the StableSwapNG pool—the "plain" pool without wrapped tokens. Most of its functions are similar to those in the second variant of pools, known as "meta pools" (CurveStableSwapMetaNG). These will be discussed later.

The first step in the deployment process is the initialization of the coin lists, the amplification factor, and the oracle time window.

During the loop over all coins, an interesting aspect involves the packing of oracle addresses and method selectors into a single 256-bit value. A 20-byte address and a 4-byte selector can be easily packed together to save gas.

Next, we address the handling of ERC4626 tokens, which have underlying assets. Operations with these tokens require understanding scale factors (derived from decimals) to operate correctly with both ERC4626 and the underlying tokens. These values are stored in the call_amount and scale_factor arrays. They are initialized here for ERC4626 tokens and later used to fetch the stored rates.

Token transfers within the pool are managed by two functions:
  • _transfer_in(): This function modifies the pool's stored_balances[coin_idx] and features two logic branches: one follows the traditional transferFrom() logic, while the other assumes an "optimistic" transfer, where the pool presumes that new tokens have already been transferred before the call to transfer_in().
  • transfer_out(): This function executes the transfer of tokens to the user straightforwardly—sends tokens via transfer() and updates self.stored_balances.
Swap
The exchange process begins with one of the following functions: exchange() or exchange_received(). They differ in the _transfer_in method used (either transferFrom() or the "optimistic" method described above). It's important to note that the second "optimistic" variant cannot be used with rebaseable tokens, as the pool's balance can change between the transfer and exchange processes. Both functions accept the indices of the coins being swapped and a _min_dy parameter, which sets the minimum amount of the received token, thereby allowing for slippage restrictions.

The next step in the exchange process within the _exchange() function is obtaining the actual balances of various coin types. This is done by calling the _balances() function, which handles rebaseable tokens by querying the actual current balanceOf(self) instead of relying on self.stored_balances[i]. Then _xp_mem() computes the actual reserve values by applying the corresponding rates.

At the core of the swap is the calculation of _dy, which represents the resulting amount of output tokens. Finally, the tokens are sent out using _transfer_out().

All the mathematical operations for exchange occur in the __exchange() function, where the main pool variable D represents the total amount of coins (with normalized balances). The amplification parameter amp, along with D and the actual reserves, is used to calculate the target amount of reserves for a token y in the pool (if x tokens were added). This helps determine dy, which is the amount of y tokens to be sent to the user. To prevent rounding issues, the target dy value is reduced by one. This rounding adjustment protects the pool from potential attacks arising from favorable rounding for the user.

The calculated dy value is then used to compute a dynamic fee. This fee is subtracted from dy and added to self.admin_balances[j]. The swap is completed by updating the protocol reserves and maintaining the oracle.
Fees
Let's return to the fees. In Curve's StableSwapNG, fees are dynamic and are deducted from the output tokens, which enhances the pool's balance (raising D and increasing the LP tokens' price). Fees also apply when adding or removing liquidity in an "imbalanced" setting, otherwise users could simply add or remove liquidity using one coin instead of executing a swap. A correct, proportional addition or removal of liquidity does not incur fees.

The "ideal" amounts of tokens are calculated based on the idea that if D increases by x%, each token balance should also increase by the same x%. The "ideal" target balance is determined using this increase of D (example here), and the difference is used to calculate the target fee. More details about fee calculation can be found in this doc.

The dynamic fee multiplier depends on the current imbalance between two tokens i and j (the formula and behavior are detailed here). In addition, there is a special self.offpeg_fee_multiplier value, which is set by the pool's governance in case the pool loses its peg.
Add/Remove Liquidity
The add_liquidity() function takes all given amounts of tokens and first checks if D hasn't decreased. The next part is crucial because StableSwapNG charges a dynamic fee on added liquidity (it's uncommon for DEXes). This fee, as described above, depends on the difference from the "ideal" balance distribution. The greater the imbalance in the asset amounts added, the higher the fee. Adding liquidity also includes slippage protection through the _min_mint_amount, which specifies the minimum amount of minted LP tokens that must be received.

Liquidity removal is performed using three different functions. The first is remove_liquidity_one_coin(), which simply allows users to withdraw a specified amount of a single coin by burning the corresponding amount of LP tokens. Slippage protection for this function is provided by the _min_received parameter, which sets the minimum amount of tokens received. The calculation of the target output token amount is executed in the _calc_withdraw_one_coin() function, where we also encounter dynamic fee calculation due to the "imbalance" operation. Additionally, inside the _calc_withdraw_one_coin() function, there is another place with rounding, where the target withdrawal amount is reduced to handle potential rounding errors.

The next function for removing liquidity is remove_liquidity_imbalance(). It accepts target token amounts, uses the _max_burn_amount parameter for slippage protection (restricting the amount of LP tokens being burnt), and allows users to remove different amounts of each coin type. In this function, the same "imbalance" fee mechanism is used.

The third option for removing liquidity is the remove_liquidity() function. It takes the amount of pool LP tokens to burn as a parameter. Slippage protection is implemented via the _min_amounts[] array, setting the minimum amounts of tokens that must be received. This variant simply sends the corresponding amount of tokens to the receiver based on the LP tokens burned and doesn't charge additional fees as there is no imbalance in the pool after this proportional liquidity removal. Another key difference with this function is that it doesn't affect the swap prices between tokens, eliminating the need to update oracles. Only the parts that change D and D_ma_time are updated. In contrast, the previous two functions require an update of the price oracle. As a result, here and here, the upkeep_oracles() function is employed (to be discussed later).
Meta Pools
After reviewing plain pools and oracles, it's time to examine meta pools. Meta pools have the same functions as plain pools, but the first coin in the coins list is an LP token from another pool (base pool). This LP token enables the meta pool to exchange it for tokens from the base pool, adding additional logic related to the underlying tokens.

Deploying a meta pool is similar to deploying a plain pool, but it also requires the base pool address and coins. Additionally, approval for token operations between the meta pool and the base pool is necessary.

This interaction between the meta pool and the base pool introduces additional logic in the transfer_in() function. If a swap occurs entirely within the base pool (i.e., both the first and second tokens are non-LP tokens from the base pool), the modification of the meta pool reserves is completely skipped. However, if an LP token is involved in the transaction, the function must mint LP tokens from the base pool here and add them to the meta pool reserves.

The _exchange_underlying() function is a key differentiator between meta pools and plain pools. It involves tokens from the base pools, requiring tracking of token indices in both the parent "meta" pool and the child "base" pool. When LP tokens are not needed (i.e., not a meta swap), a straightforward exchange in the base pool takes place.

In a meta swap involving base pool LP tokens, whenever the balances of base pool tokens change, we need to mint/burn the corresponding amounts of base pool LP tokens. We must also add/remove the underlying tokens and update the meta pool reserves to reflect these changes (this part).

Adding liquidity to the meta pool effectively means adding liquidity to the base pool (as demonstrated in the _meta_add_liquidity() function). Removing liquidity is similar to plain pool operations.
Oracles
The use of DEXes as price oracles is crucial for all of DeFi. However, directly querying current pool reserves and their ratios can be dangerous because the "instant" price can be manipulated by the addition/removal of reserves, especially in low-liquidity pools. To mitigate this risk, all modern DEXes provide swap prices as values aggregated from previous transactions/blocks. A larger time window for these Time-Weighted Average Prices (TWAPs) makes the oracle price safer but can also slow down price updates for external projects, creating arbitrage opportunities when the TWAP lags behind faster markets like centralized exchanges (CEXes).

Continuing from the previous part, let's proceed to the upkeep_oracles() function.

First, the spot price for all tokens is determined using the _get_p() function. This function returns a dynamic array of prices, representing the adjusted relative prices of each token in the pool compared to the first one, based on current balances and amplification influence. The calculation involves two loops over the tokens: the first loop calculates Dr, the adjusted D, which accounts for the "weight" of each token. In the second loop, this value is used to "weigh" each token, comparing it with the first token (represented by the xp0_A value).

These resulting spot prices are saved to the self.last_prices_packed array, along with the moving average, which in our case is the EMA (Exponential Moving Average). This type of moving average is commonly used in trading (explanation here) to give more weight to recent data points, allowing for quicker reactions to price changes. The time window parameter for StableSwapNG pools is self.ma_exp_time (set in the pool deployment phase). This parameter is passed to the _calc_moving_average() function, which calculates the EMA price for each token using the specified time window.

The last spot price and EMA price can be retrieved from the pool using two simple functions: last_price() and ema_price().
Liquidity Gauge
The LiquidityGauge is a crucial component of Curve's StableSwapNG protocol, integral to balancing incentives, governance, and efficient market operations. Learn more about Liquidity Gauges here.

Deployed through the Factory, the Gauge is connected to the LP token of a specific pool. Users can deposit LP tokens into the Gauge to earn rewards in CRV or other tokens, based on their provided LP token amount.

Non-CRV rewards are managed with administrative functions, such as deposit_reward_token() for depositing, add_reward() for registering tokens, and set_reward_distributor() to designate a distributor address.

LP tokens are added or removed from the Gauge with the deposit() and withdraw() functions, based on the user's Gauge balance. A key feature is the checkpointing mechanism within these functions.

Reward accumulation in the Gauge follows the formula:

Here, r'(t) represents the inflation rate (from the base rate and weights for gauge and gauge type), bu(t) denotes the user's LP token balance, and S(t) is the total LP tokens supplied by all users.

Operations that change balances or rates trigger updates to values, prompting StableSwapNG to track these changes and recalculate this integral for each user. Reward checkpointing occurs in the _checkpoint_rewards() function, invoked by functions affecting LP token balances.

The integral computation divides into two parts. The common part for all users excludes user-specific balances, focusing on rates r'(t) and total LP token supply S(t), and stored in the reward token configuration (here). This integral updates in this section of _checkpoint_rewards().

"Per-user" integral checkpoints (related to bu(t) ) are stored in the reward_integral_for[] mapping, updated in the next branch of _checkpoint_rewards().

Subsequent operations with rewards aim to preserve claimable amounts.

The CRV token rewards use similar tracking logic via the _checkpoint() function to update the rate. Post-calculation of the accumulated supply, the integral's "period" portion is stored and used for updating "per-user" rewards integral.

The administrative set_killed() function can halt Gauge operation in case of issues or misbehavior.
Implementation Details
An important aspect of the StableSwapNG protocol is how it handles token balances in calculations. StableSwap's D involves summing and multiplying token balances, while the DEX must manage various tokens with different precisions. To address this, all token balances used in calculations within StableSwapNG are normalized to 18-digit precision. These normalized balances are referred to as xp in the code (an example of how the _xp[] array is used can be found here).

If all tokens were standard ERC20 stablecoins, normalization would just involve adjusting the decimals for amounts. In this case, calculating D would simply involve summing all balances, normalized to 18-digit precision. However, in StableSwapNG, normalization also accommodates rebaseable tokens with rate oracles, which complicates the xp[] balances. The xp[] balance represents the token amount in "units of D". These balances are calculated here and then used to calculate D in the get_D() function.

This function highlights another interesting implementation detail: the iterative computation of D and token amounts for swaps. The StableSwap equation used in the protocol is:

Directly solving the equation for multiple token balances in Solidity is not feasible because multiplications/divisions on the right part of the equation can easily lead to overflow or precision loss. To implement such calculations in DeFi protocols, iterative algorithms are used. While the underlying math is complex, it is better described in these implementation notes. There are specific scenarios to consider when using iterative algorithms for such calculations (comment here), but these are unlikely to occur with "normal" token distributions in the pool. If they do arise, liquidity can be withdrawn from the pool.

Another important mathematical aspect of StableSwapNG is the implementation of a natural exponentiation function, inspired by this work. This could be particularly useful if you want to use natural exponentiation or logarithm functions in your protocols.

Utilizing DEXes by external protocols can be challenging without "query" functions that allow simulating swaps, calculating fees, estimating target amounts, and setting appropriate slippage restrictions. In StableSwapNG, this is managed via the CurveStableSwapNGViews.vy interface. These functions are especially useful for developers to explore because they contain state-reading logic only, without any additional storage logic.

The most significant view functions are get_dx() and get_dy(), which allow you to calculate the amounts of input/output tokens after a planned swap. There are also similar functions, get_dx_underlying() and get_dy_underlying(), for meta pools.
Conclusion
Curve's StableSwapNG is a dynamic DEX implementing the StableSwap invariant to form pools of up to eight different tokens. Its sophisticated mathematical model promotes efficient swaps between stablecoins or tokens pegged to the same base asset, minimizing slippage. Dynamic fees throughout the protocol address liquidity imbalances, keeping pools "healthy."

StableSwapNG's versatility in accommodating various tokens, including rebaseable yield-bearing tokens, expands pool utility and attracts diverse assets. Curve reinforces this with incentives for liquidity provision in both CRV and external tokens, enhancing provider participation.

To further decentralization, StableSwapNG fosters user engagement in governance voting, enabling community input on protocol decisions and improving governance structure.

StableSwapNG is a promising exploration for DeFi developers and auditors interested in innovative DEX solutions and liquidity management strategies.

That concludes this article; see you in the next one!
  • 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