In this blog post, we reveal an issue in the Trader Joe v2 Liquidity Book. This flaw lets arbitragers perform swaps without paying fees, allowing them to reclaim a portion of the fees meant for existing liquidity providers. As a result, arbitragers can swap tokens without any cost.
Who’s Trader Joe?
To understand the flaw, let's first introduce the core concepts of Trader Joe v2. Trader Joe v2 Liquidity Book (LB) is a decentralized exchange that offers concentrated liquidity. It uses the Constant Sum Market Maker Function, which means liquidity is stacked vertically across different bin arrays. This setup aims to improve trading efficiency and maximize rewards for liquidity providers.
In JOE LB, token X is called the Base Asset, and token Y is called the Quote Asset. Price P is defined as the amount of the quote token you supply (Δy) compared to the amount of the base token (Δx) you can get.
Since the price of each individual bin Pi is the same, the liquidity of each bin is defined as
Bin Composition (c), or composition factor/ratio, denotes the ratio of the remaining quote token in the current active bin.
Fee Accounting in JOE v2
There are two types of fees charged in JOE v2: the normal swap fee and the composition fee.
Normal Swap Fee
Swapping tokens in the JOE v2 Liquidity Book incurs a certain amount of fees. The basic rate applied to the total swap amount is defined as fs:
Here, fb is a fixed rate, and fv is a variable rate used to measure market volatility. For the purposes of this blog post, we can safely assume that fs is a fixed percentage for normal swaps.
Composition Fee
Another situation that requires fee accounting is when liquidity providers increase their positions in the current active bin.
Why do we need to charge fees when adding liquidity?
Remember that the liquidity of a certain bin with price Pi is defined as L = Pi · x + y. So, the liquidity for a given bin can be directly calculated from the amount of each token and is not related to the bin composition c. In theory, we can add some tokens x and y with a random composition rate to serve as supplied liquidity.
Liquidity providers (LPs) supply liquidity to a specific bin with a certain ratio of trading tokens. However, the ratio of tokens in the liquidity supplied by LPs can differ from the original ratio of tokens in that bin. A composition fee should be charged for this discrepancy between the composition of the supplied liquidity and the composition of the bin itself.
Demo - Why Charge a Composition Fee?
Here is a demo showcasing a liquidity provider, Alex, supplying liquidity into the bin with a different composition factor:
Assume the current bin's liquidity L' is purely composed of token Y (rounded rectangle in green shadow with solid border), resulting in the bin composition ratio c equals 1.
Now Alex adds liquidity ΔL into the current bin's liquidity. ΔL is purely composed of token X (in purple shadow with a solid border). After that, the total liquidity increases to L' + ΔL and the bin composition c decreases to 0.3 (for demonstration purposes). It implies that Alex gains a 70% share of the current bin's liquidity.
Immediately after adding liquidity, Alex decides to remove his 70% share. When liquidity providers (LPs) burn their shares, they should be rewarded with a proportionate amount of the total bin reserves based on their share. Thus, Alex receives 70% of both token X and token Y in the bin reserve (dotted border).
In the end, the liquidity of the current bin is still L'. Essentially, it is equivalent for Alex to just swap 70% of token Y for token X in the original bin.
If Alex is not charged any fees for his initial liquidity entrance, he effectively performs a swap without incurring a fee. This scenario highlights the importance of charging composition fees in preventing such fee-less arbitrage opportunities.
Charging the Composition Fee
Trader Joe v2 does its best to empower its liquidity providers to add liquidity with a random composition rate. It swaps LPs' liquidity to a target ratio and charges an extra composition fee fc:
where fs is the original fee being charged as if it is a normal swap.
In short, the actual composition fee rate fc is a bit higher than the normal swapping fee rate, which is somewhat reasonable because it is a pretty convenient and practical utility for LPs.
According to Trader Joe v2's whitepaper:
Liquidity that is added to the active bin will automatically be swapped across reserves if the composition is not equal to the bin’s composition. The amount swapped is calculated so that the resulting assets in the bin equal those that would result if the user were to swap prior to adding liquidity. The amount swapped incurs a fee that approximates the current market swap fee.
As JOE v2 does not perform the actual swap, it acts as if the provided liquidity ΔL is swapped to a target composition and charges the corresponding swap amount by the composition fee rate.
Compensation from Composition Fees
Now let's go back to the arbitrager Alex. When Alex adds his liquidity ΔL, a subtle amount of extra fee (in yellow shadow) is deducted from the input X, remaining amountsIn
to be calculated as the effective liquidity amount that Alex provides.
Assume there's a target composition rate c' that is a bit larger than 0.3 (the final composition).
To absorb Alex's liquidity with a different composition, Trader Joe v2 first swaps the original liquidity L' to the target composition c' with no fee. Then Alex also swaps his liquidity ΔL to the target composition c' with some composition swap fee.
Since all quote token Y in the final liquidity bin is composed of existing tokens Y in the original liquidity bin, the following equation holds true:
After the swap, the amount of token X in the original liquidity equals the amount of token Y that Alex swapped:
At this point, there is no further obstacles for Alex to enter his liquidity. Since the composition factor c' is the same, adding liquidity is literally the same to adding the corresponding trading token pair.
Implementation and the Hidden Flaw
So that's all for composition fee accounting when adding liquidity. It's time to cast light on Trader Joe v2's actual implementation, including the flaw.
In the case of entering liquidity, _mintBins()
will be called by the public mint()
function and for each bin in the LP's provided range, _updateBin()
is called to calculate LP's share for this bin.
function _updateBin(...) returns (uint256 shares, ...) {
...
(shares, amountsIn) = binReserves.getSharesAndEffectiveAmountsIn(...);
if (id == activeId) {
...
bytes32 fees = binReserves.getCompositionFees(...);
if (fees != 0) {
uint256 userLiquidity = amountsIn.sub(fees).getLiquidity(price);
uint256 binLiquidity = binReserves.getLiquidity(price);
shares = userLiquidity.mulDivRoundDown(supply, binLiquidity);
...
}
}
...
}
getSharesAndEffectiveAmountsIn()
calculates the corresponding shareshares
for LP's added liquidity and the actual trading token reserve changeamountsIn
.getCompositionFees()
calculates the corresponding composition fee for the swap to the target composition factor.the final LP's
shares
isuserLiquidity
/binLiquidity
Impact of Fee Misallocation
The subtle insight is that the composition fee
for the swap is not involved in the share calculation. Three parts make up the total liquidity of the bin: binLiquidity
, userLiquidity
and fee
. However, the liquidity provider’s share is simply put as userLiquidity / binLiquidity.
The left-alone fee, which should only be claimable by existing liquidity providers, now is silently shared across all liquidity providers.
The correct way to calculate LP’s share is userLiquidity / ( binLiquidity + fee)
Alex, who entered 70% liquidity in our previous example, will be able to have a 70% discount on the swap fees he has just paid. In an ideal situation when userLiquidity
/ binLiquidity
is large enough, like +∞, all composition fees will be returned to the LP. Arbitrager will be able to perform fee-free swaps by first entering and then immediately withdraw liquidity.
Fixing
The correct implementation is to put the composition fee into share calculation. Since the composition fee is reserved for the previous LPs, the fee parameter should also be accounted for in the denominator:
In commit 7e5b0b494, the Trader Joe Team has already fixed this issue:
if (fees != 0) {
uint256 userLiquidity = amountsIn.sub(fees).getLiquidity(price);
- uint256 binLiquidity = binReserves.getLiquidity(price);
- shares = userLiquidity.mulDivRoundDown(supply, binLiquidity);
bytes32 protocolCFees = fees.scalarMulDivBasisPointRoundDown(parameters.getProtocolShare());
if (protocolCFees != 0) {
amountsInToBin = amountsInToBin.sub(protocolCFees);
_protocolFees = _protocolFees.add(protocolCFees);
}
+ uint256 binLiquidity = binReserves.add(fees.sub(protocolCFees)).getLiquidity(price);
+ shares = userLiquidity.mulDivRoundDown(supply, binLiquidity);
parameters = _oracle.update(parameters, id);
_parameters = parameters;
After calculating the protocolCFees
, which is reserved for the protocol itself, the code adds fees.sub(protocolCFees)
to the binLiquidity
. This step ensures that only the fees excluding the protocol fees are added to the liquidity pool. Then, it calculates the user's actual share of the liquidity pool. This process is precisely how it is intended to function.
Timeline
2023-11 Reported the issue to the Trader Joe v2 on Immunefi.
2024-07 the Trader Joe Team has fixed this issue in commit 7e5b0b494.