Note (July 2022): This paper is revised since one feature described in the initial version — TWAP oracles — is removed from the smart contract due to a potential manipulation attack vector.

1. Introduction

Muffin is an automated market maker (AMM) that supports concentrated liquidity and multiple fee tiers in one pool. Other new features include limit range orders and internal accounts. All are built in a gas-optimized single-contract design.

We will describe each feature’s detail and explain some key concepts of the technical implementation and the derivations of the related formulas.

2. Features

2.1 Concentrated liquidity

Muffin inherits the existing design of concentrated liquidity. Traditionally, x*y=k AMMs force liquidity providers (LPs) to put liquidity on the price range from 0 to infinity. It is wasteful because a large part of the liquidity can never be put into service. Concentrated liquidity means that now LPs can select their own price ranges to concentrate their capital there as a way to provide a great amount of liquidity to the pool. LPs can accrue fees from the swaps that are executed in their selected price ranges.

2.2 Multiple fee tiers per pool

The typical design of AMM has been “one fixed fee tier for one pool” and to create several pools with different fee tiers to suit the market needs.

In Muffin, we change the design to “multiple fee tiers for one pool” and create only one pool to serve the needs of different fee tiers for a token pair. The hierarchical change enables a data structure that can afford more choices of fee tiers and make cross-tier order optimization more gas-efficient.

On a data structure level, each tier of a pool in Muffin represents a percentage swap fee, and has its independent liquidity, price, and tick states, acting like an “inner pool” by itself. Despite that, tiers don’t have their own tick spacing settings and TWAP oracles. Instead, they share the same one together in a pool, reducing extra data read and write when executing a swap across multiple fee tiers.

2.2.1 Choices of fee tiers

For LPs, the experience of providing liquidity in Muffin is similar to that in Uniswap V3 —— pick a fee tier, then pick a price range. The core difference is LPs are now given more fee tiers to choose.

Unlike the typical AMM design, the available choices of fee tiers are not preset at protocol level. Instead, they can be uniquely set for each pool. For example, we can have a set of 0.5, 1, 3, and 5 bps fees for stablecoin pairs, and at the same 5, 20, 40, and 60 bps fees for ETH-USDC or another token pair.

To retain flexibility, Muffin’s core contract itself does not enforce any rules about which token pair should have what fee tiers. At launch, fee tiers of common token pairs will be initially decided by the Muffin’s team, while the publicly created pool will be by default allowed to use 40, 70, 100 bps, which are a set of fee tiers suitable to more exotic token pairs. This setting is changeable by the governance contract.

2.2.2 Maximum number of fee tiers

There is an upper limit on how many tiers are available per pool. This is a protocol-level setting, i.e. applied to all pools in the protocol. The number is decided solely based on its impact on the swap’s gas cost, which is more of an economic consideration than a technical constraint.

For example, on Ethereum, Muffin will offer up to 6 tiers per pool, and we’ll practically set 3–4 tiers per pool considering the current expensive gas price. On L2s or other chains with low gas price, this number will be tuned up much further (possibly 12–16).

2.2.3 Order optimization

For traders, when you swap on Muffin, the protocol will split your order into several smaller orders and route them into different fee tiers to give you an optimal exchange rate.

The split path is calculated in the Muffin’s contract right before the swap is executed, contrary to traditional routers pre-determining the path off-chain before submitting the transaction. This is important because when the number of fee tiers increases and there are other orders being executed in front of yours, the extent of the predetermined path becoming suboptimal also increases.

2.2.3.1 Maths

The calculation takes account of the order size and also each tier’s fee rate, current price and liquidity level. Suppose Alice wants to swap $\Delta$ units of token X for token Y in a pool of $n$ tiers. The protocol then splits her order and routes $\delta_i$ units of token X to each tier, i.e. $\sum_{i=1}^{n}{\delta_i} = \Delta$.

Using each tier’s current virtual reserves $x_i$ and $y_i$, its liquidity level $L_i = \sqrt{x_i y_i}$, and its percentage swap fee $(1-\gamma_i)$, we can formulate the total output amount of token Y, which is our objective function to maximize.

$$ \begin{split} & \text{maximize} \hspace{1mm} \sum_{i=1}^n{\left(y_i - \frac{L_i^2}{x_i+\gamma_i\delta_i}\right)} \newline & \text{subject to} \hspace{1mm} \sum_{i=1}^{n}{\delta_i} = \Delta \end{split} $$

Solving this optimization problem gives us the formula of the sub-order’s optimal size $\delta_i^*$, which we use to determine how the order is split on-chain. Full derivation is in the appendix section.

$$ \delta_i^* = \frac{L_i}{\sqrt{\gamma_i}} \frac{\Delta + \sum_{j=1}^n \frac{x_j}{\gamma_j}} {\sum_{j=1}^n \frac{L_j}{\sqrt{\gamma_j}}} - \frac{x_i}{\gamma _i}, \hspace{6mm} i=1, …, n $$

2.2.4 Combining with concentrated liquidity

The maths described in the last section assumes tiers’ liquidity levels always stay unchanged during a swap, which is not true in concentrated liquidity AMM. To incorporate the optimization into concentrated liquidity, here we adopt a greedy strategy:

  1. When an order comes, the protocol splits the order by considering the order size and each tier’s current price, liquidity level, and fee rate.
  2. Each tier executes its sub-order within its current tick space, and stops when its price hits the boundary of its current tick space.
  3. Those stopped tiers, which cannot finish the whole sub-order within their current tick space, will cross to their next tick space and, at the same time, update their liquidity level.
  4. The remaining input tokens are gathered, and the whole process repeats until all input tokens are swapped.

Note that, in principle, such greedy strategy does not produce an exact optimal solution, but it approximates well under a reasonable number of steps, therefore keeping the gas cost to perform it on-chain low enough.

2.2.4.1 Manually including / excluding tiers

Muffin allows traders to specify which tiers to include and exclude when splitting their orders. Sometimes, a tier can have a very low liquidity level to the extent that fetching its state from chain and including it in the optimization calculation reversely cause more gas fees than what the optimization can save for the trader. In this case, the frontend app should better exclude those tiers for traders.

Such on-chain order optimization is not mandatory. Considering the gas cost, it is still possible to be more cost-effective for tiny orders to pre-calculate split path off-chain, or even not split the order at all.

2.2.5 Observations

By simulations, we try to understand how the tier’s price moves when incoming orders are split in the way just mentioned. Each colored line in the graph below represents a different $\gamma$, i.e. one minus the fee rate.

Example of tiers’ price movements

Example of tiers’ price movements

We can see the prices of the lower fee tiers swing up and down more vigorously comparing to the prices of the higher fee tiers. This matches our intuition. Intuitively, the pool should distribute a larger trade volume to the tiers with lower fee rates so as to optimize exchange rate. And for an AMM, each trade is an impact on price. So, more trades on the lower fee tiers lead to more price fluctuations there.

Also, we can see the pattern that each price has a maximum distance to another. Technically, the relationship between two tier prices is approximately $\frac{\gamma_j}{\gamma_i} \le \frac{P_i}{P_j} \le \frac{\gamma_i}{\gamma_j}$ for $\gamma_i$ > $\gamma_j$. The full derivation is in the appendix section.

2.3 Limit range orders

2.3.1 Primer: Limit order

Limit order refers to buy or sell tokens at a target price specified by the trader. Most limit order protocols rely on external keepers to execute orders, and they do it only when they see arbitrage opportunity —— the order’s buying price is higher than the market’s selling price, and closing the price gap brings a profit that is larger than the gas cost for doing so.

2.3.2 Primer: Range order

Uniswap V3 introduced an order type called “range order”. It refers to traders creating a liquidity position to sell an underlying token along the AMM curve when the price moves.

Creating narrow positions can imitate limit orders. However, traders need to monitor their positions and withdraw once they entirely sell out their tokens; otherwise, if the price moves backward, their positions will automatically buy back what they have just sold.

2.3.3 Limit range order and settlement

Muffin introduces “limit range orders”, i.e. range orders (with specific widths) that will be automatically settled once they are entirely filled. This is done without keepers.

When any swap moves the price to cross a tick at which there’re limit-range-order positions just fully converted to the target token, the protocol will “settle” these positions as if their owners withdraw them in the same transaction with the swap. After that, these position’s liquidity will not be put into service again even if the price moves back. The positions will stay single-sided. It feels similar to traditional limit orders being filled and settled.

The position stays as 100% DAI even when the price moves backward

The position stays as 100% DAI even when the price moves backward

2.3.4 Fee accrual

Limit range orders also accrue fees from the swaps that happen inside their price range before they are being settled.

Technically, when the positions are being settled, the protocol will snapshot the current values of “fee growths” of their price range. Then, when the LPs withdraw their assets, the snapshot is used to calculate how many swap fees the positions had accrued before the settlement.

2.3.5 Requirement and caveat

Muffin allows LPs to set their position to “limit range order” as long as the width of the position’s price range matches the one that the protocol requires. By default, we’ll set the required width to be as narrow as one tickSpacing of the pool to imitate limit order as much as possible.

Each pool tier can set its own range width requirement for limit range orders, and can also disable this feature. One caveat of enabling it is that it could increase gas cost for a tick-crossing swap. On Ethereum, a realistic setting would be to enable it on only the first tier of each pool, assuming normal users care about the existence of the settlement functionality more than the amount of swap fees they can earn. This is the default setting for all the pools in Muffin.

2.3.6 Average execution price

We are probably curious on the average execution price of limit range orders, since tokens are now exchanged along a price curve instead of on a fixed price point. To find it, we can simply calculate how many tokens we should deposit and can withdraw for a single-sided position.

$$ \text{Average price} = \frac{\Delta y}{\Delta x} = \frac {L \left(\sqrt{P_\text{upper}} - \sqrt{P_\text{lower}} \right)} {L \left(\frac{1}{\sqrt{P_\text{lower}}} - \frac{1}{\sqrt{P_\text{upper}}} \right)} = \sqrt{P_\text{lower} P_\text{upper}} $$

2.4 Single-contract design

In contrast with the typical way of creating one contract per pool, all pools in Muffin stay inside one contract under one contract address.

With this design, token reserves of all pools are stored under one address. It reduces the gas cost of multihop swaps, as we no longer need to transfer intermediate tokens across addresses. It also removes the need to call multiple pool contracts and have an external router to orchestrate these hops. Besides multihop swap, any zaps or multicall that interact with multiple pools can now have a lower gas cost.

2.5 Internal accounts

Muffin allows users to deposit ERC-20 tokens into their own internal accounts in the Muffin’s contract. Users can use the accounts to pay and receive tokens in swapping and adding/removing liquidity. Together with the previously mentioned single-contract design, the primary goal is to reduce the need for token transfers for frequent traders and active liquidity providers, reducing their gas expenditure.

3. Technical concepts

The rest of the paper is about the technical implementation of Muffin in Solidity. We will highlight some noteworthy key concepts here.

3.1 Data hierarchy

As mentioned, we use a single contract to store all pools and handle all swaps and adding/removing liquidity there for all the pools. We call this contract the “hub”, i.e.:

The hub contains all the pool states. Each pool represents a token pair and contains various tiers. And each tier represents a swap fee and has its own price, liquidity and tick states.

3.2 Price

Each tier has its independent price. Mathematically, the price of a tier is equal to the ratio of the tier’s virtual reserve of an underlying token to that of another, i.e. $P=\frac{y}{x}$. To make calculation convenient in the contract, we store the square root price $\sqrt{P}$.

The square root price in the contract is a UQ56.72 fixed-point number stored as a uint128 variable. Although it has 72 fraction bits, our actual minimum supported square root price is about $2^{-56}$, because when the value goes smaller, it is difficult to calculate its corresponding tick index on-chain accurately. In most cases, a UQ56.72 fixed-point is spacious and precise enough to represent most of the realistic price ratios in the market.

3.3 Ticks

Price is partitioned into ticks. Each tick represents a 0.01% price difference to its adjacent ticks, i.e. $P_i = 1.0001^i$ where $P$ is the price and $i$ is the tick index, or specifically $\sqrt{P_i} = \sqrt{1.0001}^{i}$ as we store square root price in the contract. So, $i = \log_{\sqrt{1.0001}}\sqrt{P_i}$.

The supported tick range in Muffin is $[-776363, 776363]$, which the corresponding square root prices maximally fit inside the price range $[2^{-56}, 2^{56}]$. Therefore, the exact supported price range in Muffin is $\left[1.0001^{-776363},1.0001^{776363}\right]$.

Various optimizations are made to make finding the next initialized tick easier (an initialized tick means there are positions using it as their price boundary). For example:

  • Each tier use nested bitmaps to track which ticks are initialized.
  • Each tick tracks its adjacent upper and lower initialized ticks, working as a doubly-linked list.
  • Each tier caches the next initialized ticks below and above its current tick.

3.4 Tick spacing

Ticks are used for LP positions to represent their upper and lower price boundaries. However, not all ticks can be used. There is a tick spacing setting for each pool, and only ticks that are multiples of that tick spacing can be used.

When a pool is created, it will use the default tick spacing that is set in the protocol. This default can be changed. Note that the pool’s tick spacing can also be changed, in contrast to the immutable nature in other existing AMMs. Also, each pool has only one tick spacing, which applies to all the tiers in the pool.

3.5 Liquidity level

The active liquidity level $L$ of a pool tier (also known as virtual liquidity) is defined as $L = \sqrt{xy}$, where $x$ and $y$ are the current virtual reserves of the two underlying tokens. It is to quantify the amount of liquidity within the current tick.

The liquidity level in the tier state is stored as an uint128, but in the tick, position and settlement states, it is stored as a multiple of $2^8$ as an uint96 with a variable name suffix D8 to indicate this. The primary reason is that we can store liquidity level using few bits and therefore pack variables more tightly to reduce the amount of SLOAD and SSTORE operations.

The protocol does not directly store the value of liquidity level for each tick. Instead, each tier state only keep tracks of the current tick’s liquidity level, liquidityD8. Then, each tick state stores the deltas of liquidity level, namely liquidityLowerD8 and liquidityUpperD8, which are respectively the positive and negative deltas of liquidity level when the tick is crossed from lower to higher price. With these data, we can derive the liquidity levels of all ticks.

3.6 Fee rate

Each tier has its own independent percentage swap fee rate. To make calculation convenient in the contract, rather than storing the exact percentage value $(1-\gamma)$, we store the square root of one minus it, i.e. $\sqrt{\gamma}$. In the code, it is represented by the variable name sqrtGamma, and is a 6-digit decimal fixed point stored as an uint24 variable.

3.7 Fee growths

Fee growth of a particular range is defined as “the amount of fees accrued per unit of virtual liquidity in that particular range throughout the entire history of the tier”. It is also sometimes called the “fee-per-liquidity accumulator”.

3.7.1 Tier’s feeGrowthGlobal

Each tier keep tracks of two values feeGrowthGlobal{0,1}, denoted by $f_g$. They are the fee growths of the entire price range of the tier. Simply put, these are the amounts of fees you would earn if you provide one unit of full-range liquidity in the tier from the moment it is created.

3.7.2 Tick’s feeGrowthOutside

Each tick tracks two values feeGrowthOutside{0,1}, denoted by $f_o$. They are the fee growths of the range from the tick itself outwards to the end of the supported tick range. You can think of “outward” as the direction at which you stand on the current price looking at the tick and towards the end of the whole price range.

Shaded areas represents feeGrowthOutside.
Drag the current tick to see the “flipping” of feeGrowthOutside.

3.7.3 Position’s feeGrowthInside

The values above are used together to find out the fee growths of a particular tick range, or feeGrowthInside{0,1}, denoted by $f_r$. Assume the current tick is $i$, and the $f_r$ from tick $a$ to tick $b$ is defined as:

$$ f_{r,a,b} = \begin{cases} f_g - f_{o,a} - f_{o,b} & a \le i < b \newline f_{o,a} - f_{o,b} & i < a \newline f_{o,b} - f_{o,a} & i \ge b \newline \end{cases} $$

The values $f_{r,a,b}$ are then used to calculate the amount of fees earned by a position. We will cover it in a section below.

3.8 Positions

Every user can provide liquidity to Muffin by creating a position. Each position in a pool is distinct by the combination of:

  • Position owner address
  • Position reference id
  • Tier index
  • Lower and upper tick boundaries

3.8.1 Position reference id

Position reference id, or positionRefId in the code, is an arbitrary number set by the position owner. It is expected that the position owner is in fact a “position manager” contract, and the position manager can make use of positionRefId to separate positions of the same tick range on the same pool tier that are however owned by different end clients.

3.8.2 Tier index and tick boundaries

They are namely which tier and what price range the position’s liquidity is being served at. There’re no restriction on these settings except tick boundaries have to be a multiple of the pool’s tickSpacing.

3.8.3 Position’s liquidity level

It is to numerically represent the amount of liquidity your position contributes within its price range. You can think about your position like a bucket of liquidity — price range is the width of the bucket, and liquidity level is the water level of the bucket.

So, for the same amount of liquidity you pour into a position, a narrower price range gives you a higher liquidity level, which gives you a higher shares of swap fees within that price range, although it also means your liquidity will be used up faster, i.e. fully converted to single-sided.

The exact value of liquidity level is rarely the focal point. However, the proportional increase in liquidity level by narrowing price range is important to know. This is called the capital efficiency.

3.8.4 Conversion between liquidity and token amounts

Given the tier’s current price $P_\text{now}$, the quantities of the underlying tokens, $x$ and $y$, of a position with a liquidity level $l$ and price range $[P_\text{lower}, P_\text{upper}]$ are defined as:

$$ x = l \left( \frac{1}{\sqrt{P}} - \frac{1}{\sqrt{P_\text{upper}}} \right) $$

$$ y = l \left(\sqrt{P} - \sqrt{P_\text{lower}} \right) $$

where $P = \min \{ \max \{ P_\text{now}, P_\text{lower} \}, P_\text{upper} \}$, i.e. the current price bounded in the position’s price range.

Rearranging the terms, when an LP provides $x$ and $y$ units of underlying tokens, the liquidity level $l$ of the position is defined as:

$$ l = \min \left\{ \frac{x}{\left( \frac{1}{\sqrt{P}} - \frac{1}{\sqrt{P_\text{upper}}} \right)}, \frac{y}{\left(\sqrt{P} - \sqrt{P_\text{lower}} \right)} \right\} $$

3.8.5 Fee calculation

When a position is created, the protocol will snapshot the value of the fee growth per unit of virtual liquidity of the position’s price range for each underlying token, i.e. the position’s feeGrowthInside{0,1} in the code.

Later, the protocol can find out the position’s unclaimed fees by first calculating the delta between the snapshot value $f_{rs}$ and the current value $f_{rc}$. This delta value is namely the fee accrued per unit of virtual liquidity in the position’s price range from when the position is created till now. Multiplying it by the position’s liquidity level $l$ gives the amount of fees to claim.

$$ \text{fee unclaimed} = (f_{rc} - f_{rs}) \cdot l $$

3.8.6 Fee accounting

Now we talk about how data are stored for fee calculating. First of all, the position itself does not directly store the amount of unclaimed fees. It is always calculated with feeGrowthInside{0,1} every time you look for it.

3.8.6.1 Adding liquidity

When adding liquidity to a position, the position’s feeGrowthInside{0,1} are updated so as to accrue fees without the immediate need to claim and move the fees from the position to the LP’s account, therefore reducing unnecessary SSTORE operations to save gas.

$$ \begin{gather} \text{fee_out} = \ 0 \newline f_{rs} := \ f_{rc} - \frac{l}{l’} \left(f_{rc} - f_{rs}\right) \end{gather} $$

3.8.6.1 Removing liquidity

When removing liquidity, the position’s feeGrowthInside{0,1} remain unchanged this time, but partial fees are transferred to the LP’s account proportionally to the amount of liquidity removed. In other words, if $n$% of liquidity is removed, then $n$% of unclaimed fees will be claimed.

$$ \begin{gather} \text{fee_out} = \left( f_{rc} - f_{rs} \right)\left(l - l’\right) \newline f_{rs} := f_{rs} \end{gather} $$

LPs can also choose to claim all fees at once when removing liquidity, regardless of the amount of liquidity being removed. In this case, the position’s feeGrowthInside{0,1} are directly replaced by the current values $f_{rc}$.

$$ \begin{gather} \text{fee_out} = \left( f_{rc} - f_{rs} \right) \cdot l \newline f_{rs} := f_{rc} \end{gather} $$

3.9 Reentrancy locks

The hub contract has no contract-level reentrancy lock. Since all pools are now stored in one contract, the standard contract-level reentrancy lock would be too strict blocking any subsequent interactions called inside callback functions that interact with other pools.

3.9.1 Token lock

Instead of contract-level lock, each token has its own “re-input” lock.

When an user performs an action that requires inputting tokens to the protocol (e.g. swap), the protocol will call a callback function on the msg.sender to request those tokens, and later check the difference in token balance to determine whether msg.sender has sent in tokens.

The protocol will “lock” the token before calling the callback function, and “unlock” the token right after the callback function is done. Once locked, the protocol cannot request the locked token again in any other subsequent contract interactions until the token is unlocked. It blocks users from attempting to be requested the same token twice. Otherwise, the protocol could double-count user’s input, which could drain the protocol reserves.

3.9.2 Pool lock

To add extra safety, each pool also has its own “re-interact” lock. Pool will be locked once being interacted for a state-changing action (e.g. swap, add/remove liquidity, etc), and will be unlocked only after the callback function is called and all state effects have been done. This blocks users from interacting the same pool again in the callback function.

4. Appendices

4.1 Deriving sub-order’s optimal size

First, we start from the constant product function of the x*y=k AMM:

$$ \left(x + \gamma \cdot x_{in} \right) (y - y_{out}) = L^2 $$

Rearranging the terms, we have:

$$ y_{out} = y - \frac{L^2}{x + \gamma \cdot x_{in}} $$

Suppose we have a $n$-tier pool for the tokens X and Y. We want to sell $\Delta$ units of token X to the pool. The pool splits our order and eventually we’re selling $\delta_i$ units of token X to each tier $i$, i.e. $\sum_{i=1}^{n}{\delta_i} = \Delta$. We want to maximize the total output amount of token Y we receive from the pool. Using the formula of $y_{out}$ above, we can formulate the optimization problem:

$$ \begin{split} & \text{minimize} \hspace{3mm} f\left(\delta_1, … , \delta_n \right) = \sum_{i=1}^n \left(\frac{L_i^2}{x_i + \gamma_i \delta_i}\right) \newline & \text{subject to}\hspace{3mm} g\left(\delta_1, …, \delta_n \right) = \sum_{i=1}^n \delta_i - \Delta = 0 \end{split} $$

There are several ways to solve it. Here, we use a more elementary approach. First, we solve the constraint function for one of the variables, say, $\delta_n$:

$$ \delta_n = \Delta - \sum_{i=1}^{n-1} \delta_i $$

Then, we substitute this expression back to the $f$, so we get a new optimization problem with one less variable.

$$ \begin{split} f\left(\delta_1, …, \delta_{n-1} \right)
& = \sum_{i=1}^n \frac{L_i^2}{x_i + \gamma_i \delta_i} \newline & = \sum_{i=1}^{n-1} \frac{L_i^2}{x_i + \gamma_i \delta_i} + \frac{L_n^2}{x_n + \gamma_n \delta_n} \newline & = \sum_{i=1}^{n-1} \frac{L_i^2}{x_i + \gamma_i \delta_i} + \frac{L_n^2}{x_n + \gamma_n \left( \Delta - \sum_{i=1}^{n-1} \delta_i \right)} \end{split} $$

Then, we take the partial derivative with respect to $\delta_i$:

$$ \frac{\partial f}{\partial \delta_i} = \frac{L_i^2 \gamma_i}{\left( x_i + \gamma_i \delta_i \right)^2} + \frac{L_n^2 \gamma_n}{\left(x_n + \gamma_n \left( \Delta - \sum_{i=1}^{n-1} \delta_i \right)\right)^2} = 0, \hspace{6mm} i = 1, …, n - 1 $$

For simplicity, we denote the second term above by $\lambda$. Then, we’ll get a set of equations for $\delta_i$:

$$ \delta_i = \frac{L_i}{\sqrt{\gamma _i} \sqrt{\lambda } } - \frac{x_i}{\gamma _i}, \hspace{6mm} i = 1, …, n $$

We substitute these equations to the constraint $g$:

$$ \sum_{i=1}^n \delta_i = \sum_{i=1}^n \left( \frac{L_i}{\sqrt{\gamma _i} \sqrt{\lambda } }-\frac{x_i}{\gamma _i} \right) =\Delta $$

$$ \frac{1}{\sqrt{\lambda}} = \frac{\Delta+\sum_{i=1}^n \frac{x_i}{\gamma_i}} {\sum_{i=1}^n \frac{L_i}{\sqrt{\gamma_i}}} $$

Then, substitute this back to $\delta_i$ and we have the solution to the optimization problem, i.e. the token amount $\delta_i$ we should sell to each tier $i$ respectively to get an overall best swap rate.

$$ \delta_i^* = \frac{L_i}{\sqrt{\gamma_i}}\frac{\Delta+\sum_{j=1}^n \frac{x_j}{\gamma_j}} {\sum_{j=1}^n \frac{L_j}{\sqrt{\gamma_j}}} - \frac{x_i}{\gamma _i}, \hspace{6mm} i = 1, …, n $$

Similarly, we can find the solution to an “exact output” order, i.e. having an exact desired amount of token to buy instead of sell.

$$ \delta_i^* = y_i- \frac{L_i}{\sqrt{\gamma_i}} \frac{-\Delta+\sum_{j=1}^n y_j}{\sum_{j=1}^n \frac{L_j}{\sqrt{\gamma_j}}}, \hspace{6mm} i = 1, …, n $$

Note that $\delta_i^*$ needs to be non-negative but is however unbounded in the optimization problem. In practice, when a solution is found negative, we set it to zero and then exclude that tier from the optimization problem and solve it again. The worst case asymptotic complexity here is $O(n^2)$, nonetheless the gas usage is still relatively low.

4.2 Relationship between tiers’ prices

Let’s go further to see the relationship between tier’s prices. Specifically, if an order selling the quote currency is split and routed to a set of tiers, the post-swap prices $P$ of any two tiers from the set will behave in this relationship $P_i:P_j = \gamma_j:\gamma_i$. We can show it by using the price formula of AMM, $P = y/x = \left(L/x\right)^2$, to express $P$ with the $\lambda$ defined above.

$$ P_i = \left(\frac{L_i}{x_i + \gamma_i \delta_i}\right)^2 = \left(\frac{L_i}{x_i + \gamma_i \left(\frac{L_i}{\sqrt{\gamma _i} \sqrt{\lambda } } - \frac{x_i}{\gamma _i}\right)} \right)^2 = \left(\frac{\sqrt{\lambda}}{\sqrt{\gamma_i}}\right)^2 = \frac{\lambda}{\gamma_i} $$

$$ \frac{P_i}{P_j} = \frac{\frac{\lambda}{\gamma_i}}{\frac{\lambda}{\gamma_j}} = \frac{\gamma_j}{\gamma_i} $$

Using the same approach, we can know the relationship between the tiers’ prices after being sold the base currency is $P_i:P_j = \gamma_i:\gamma_j$. These lead to the conclusion below for $\gamma_i > \gamma_j$ :

$$ \frac{\gamma_j}{\gamma_i} \le \frac{P_i}{P_j} \le \frac{\gamma_i}{\gamma_j} $$

Note that this is derived without considering the implementation we use to take account of the change in tier’s liquidity during a swap in a concentrated liquidity design (as described in section 2.2.3). As a result, the actual price movements will approximately but not perfectly exhibit this pattern.