Trader Joe v2 contest
Findings & Analysis Report
2022-12-22
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] Transfering funds to yourself increases your balance
- [H-02] Incorrect output amount calculation for Trader Joe V1 pools
- [H-03] Wrong implementation of function
LBPair.setFeeParameter
can break the funcionality of LBPair and make user’s tokens locked - [H-04] Wrong calculation in function
LBRouter._getAmountsIn
make user lose a lot of tokens when swap through JoePair (most of them will gifted to JoePair freely) - [H-05] Attacker can steal entire reserves by abusing fee calculation
-
- [M-01]
LBRouter.removeLiquidity
returning wrong values - [M-02]
beforeTokenTransfer
called with wrong parameters inLBToken._burn
- [M-03] Flashloan fee collection mechanism can be easily manipulated
- [M-04] Very critical
Owner
privileges can cause complete destruction of the project in a possible privateKey exploit - [M-05] Attacker can keep fees max at no cost
- [M-06] Calling
swapAVAXForExactTokens
function while sending excess amount cannot refund such excess amount - [M-07] Incorrect fee calculation on LBPair (fees collected on swaps are less than what they “should” be)
- [M-01]
-
- G-01 Owner token enumeration is an extremely expensive operation but it is not essential to the protocol
- G-02 Using Solidity version 0.8.17 will provide an overall gas optimization
- G-03 Ternary operation is cheaper than if-else statement
- G-04 Checking
msg.sender
to not be zero address is redundant - G-05 An element is cached to memory after it is used
- G-06 Divisions by
2**n
can be replaced by right shift byn
- G-07 Runtime cost can be optimized in detriment of the deploy cost
- G-08 Making constant variables private will save gas during deployment
- G-09 Using
bool
s for storage incurs overhead - G‑10 Functions guaranteed to revert when called by normal users can be marked
payable
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit contest is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit contest outlined in this document, C4 conducted an analysis of the Trader Joe v2 smart contract system written in Solidity. The audit contest took place between October 14—October 23 2022.
Wardens
83 Wardens contributed reports to the Trader Joe v2 contest:
- 0x1f8b
- 0x4non
- 0x52
- 0xSmartContract
- 8olidity
- Aymen0909
- Dravee
- ElKu
- IllIllI
- JMukesh
- Jeiwan
- Josiah
- KIntern_NA (TrungOre and duc)
- KingNFT
- Lambda
- LeoS
- M4TZ1P (DekaiHako, holyhansss_kr, Zer0Luck, AAIIWITF, and exd0tpy)
- Mathieu
- MiloTruck
- Mukund
- Nyx
- Rahoz
- Randyyy
- RaoulSchaffranek
- ReyAdmirado
- Rolezn
- RustyRabbit
- SEVEN
- Saintcode_
- Shishigami
- SooYa
- The_GUILD (David_, Ephraim, LeoGold, and greatsamist)
- TomJ
- Trust
- Tutturu
- __141345__
- adriro
- bctester
- bitbopper
- brgltd
- c3phas
- catchup
- cccz
- chaduke
- csanuragjain
- d3e4
- djxploit
- hansfriese
- hxzy
- hyh
- imare
- immeas
- indijanc
- ladboy233
- leosathya
- lukris02
- m_Rassska
- ne0n
- neumo
- parashar
- pashov
- pfapostol
- phaze
- philogy
- rbserver
- rvierdiiev
- saian
- sha256yan
- shung
- sorrynotsorry
- supernova
- vv7
- wagmi
- zzykxx
- zzzitron
This contest was judged by Alex the Entreprenerd.
Final report assembled by liveactionllama.
Summary
The C4 analysis yielded an aggregated total of 12 unique vulnerabilities. Of these vulnerabilities, 5 received a risk rating in the category of HIGH severity and 7 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 11 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 14 reports recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 Trader Joe v2 contest repository, and is composed of 26 smart contracts written in the Solidity programming language and includes 4,598 lines of Solidity code.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities according to a methodology based on OWASP standards.
Vulnerabilities are divided into three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
Further information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website.
High Risk Findings (5)
[H-01] Transfering funds to yourself increases your balance
Submitted by Dravee, also found by bitbopper, hansfriese, saian, Tutturu, JMukesh, __141345__, neumo, parashar, Randyyy, phaze, hxzy, Lambda, cccz, SEVEN, ne0n, 8olidity, and RaoulSchaffranek
https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBToken.sol#L182
https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBToken.sol#L187
https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBToken.sol#L189-L192
Using temporary variables to update balances is a dangerous construction that has led to several hacks in the past. Here, we can see that _toBalance
can overwrite _fromBalance
:
File: LBToken.sol
176: function _transfer(
177: address _from,
178: address _to,
179: uint256 _id,
180: uint256 _amount
181: ) internal virtual {
182: uint256 _fromBalance = _balances[_id][_from];
...
187: uint256 _toBalance = _balances[_id][_to];
188:
189: unchecked {
190: _balances[_id][_from] = _fromBalance - _amount;
191: _balances[_id][_to] = _toBalance + _amount; //@audit : if _from == _to : rekt
192: }
..
196: }
Furthermore, the safeTransferFrom
function has the checkApproval
modifier which passes without any limit if _owner == _spender
:
File: LBToken.sol
32: modifier checkApproval(address _from, address _spender) {
33: if (!_isApprovedForAll(_from, _spender)) revert LBToken__SpenderNotApproved(_from, _spender);
34: _;
35: }
...
131: function safeTransferFrom(
...
136: ) public virtual override checkAddresses(_from, _to) checkApproval(_from, msg.sender) {
...
269: function _isApprovedForAll(address _owner, address _spender) internal view virtual returns (bool) {
270: return _owner == _spender || _spenderApprovals[_owner][_spender];
271: }
Proof of Concept
Add the following test to LBToken.t.sol
(run it with forge test --match-path test/LBToken.t.sol --match-test testSafeTransferFromOneself -vvvv
):
function testSafeTransferFromOneself() public {
uint256 amountIn = 1e18;
(uint256[] memory _ids, , , ) = addLiquidity(amountIn, ID_ONE, 5, 0);
uint256 initialBalance = pair.balanceOf(DEV, _ids[0]);
assertEq(initialBalance, 333333333333333333); // using hardcoded value to ease understanding
pair.safeTransferFrom(DEV, DEV, _ids[0], initialBalance); //transfering to oneself
uint256 rektBalance1 = pair.balanceOf(DEV, _ids[0]); //computing new balance
assertEq(rektBalance1, 2 * initialBalance); // the new balance is twice the initial one
assertEq(rektBalance1, 666666666666666666); // using hardcoded value to ease understanding
}
As we can see here, this test checks that transfering all your funds to yourself doubles your balance, and it’s passing. This can be repeated again and again to increase your balance.
Recommended Mitigation Steps
- Add checks to make sure that
_from != _to
because that shouldn’t be useful anyway - Prefer the following:
File: LBToken.sol
189: unchecked {
190: _balances[_id][_from] -= _amount;
191: _balances[_id][_to] += _amount;
192: }
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) commented:
The Warden has shown how, due to the improper usage of a supporting temporary variable, balance duplication can be achieved.
Mitigation will require ensuring that the intended variable is changed in storage, and the code offered by the warden should help produce a test case to compare the fix against.
Because the finding pertains to duplication of balances, causing a loss for users, I agree with High Severity.
[H-02] Incorrect output amount calculation for Trader Joe V1 pools
Submitted by Jeiwan, also found by KIntern_NA and cccz
https://github.com/code-423n4/2022-10-traderjoe/blob/main/src/LBRouter.sol#L891
https://github.com/code-423n4/2022-10-traderjoe/blob/main/src/LBRouter.sol#L896
Output amount is calculated incorrectly for a Trader Joe V1 pool when swapping tokens across multiple pools and some of the pools in the chain are V1 ones. Calculated amounts will always be smaller than expected ones, which will always affect chained swaps that include V1 pools.
Proof of Concept
LBRouter is a high-level contract that serves as the main contract users will interact with. The contract implements a lot of security checks and helper functions that make usage of LBPair contracts easier and more user-friendly. Some examples of such functions:
- swapExactTokensForTokensSupportingFeeOnTransferTokens, which makes chained swaps (i.e. swaps between tokens that don’t have a pair) of tokens implementing fee on transfer (i.e. there’s fee reduced from every transferred amount);
- swapExactTokensForAVAXSupportingFeeOnTransferTokens, which is the variation of the above function which takes AVAX as the output token;
- swapExactAVAXForTokensSupportingFeeOnTransferTokens, which is the variation of the previous function which takes AVA as the input token.
Under the hood, these three functions call _swapSupportingFeeOnTransferTokens, which is the function that actually performs swaps. The function supports both Trader Joe V1 and V2 pools: when _binStep
is 0 (which is never true in V2 pools), it’s assumed that the current pool is a V1 one. For V1 pools, the function calculates output amounts based on pools’ reserves and balances:
if (_binStep == 0) {
(uint256 _reserve0, uint256 _reserve1, ) = IJoePair(_pair).getReserves();
if (_token < _tokenNext) {
uint256 _balance = _token.balanceOf(_pair);
uint256 _amountOut = (_reserve1 * (_balance - _reserve0) * 997) / (_balance * 1_000);
IJoePair(_pair).swap(0, _amountOut, _recipient, "");
} else {
uint256 _balance = _token.balanceOf(_pair);
uint256 _amountOut = (_reserve0 * (_balance - _reserve1) * 997) / (_balance * 1_000);
IJoePair(_pair).swap(_amountOut, 0, _recipient, "");
}
} else {
ILBPair(_pair).swap(_tokenNext == ILBPair(_pair).tokenY(), _recipient);
}
However, these calculations are incorrect. Here’s the difference:
@@ -888,12 +888,14 @@ contract LBRouter is ILBRouter {
(uint256 _reserve0, uint256 _reserve1, ) = IJoePair(_pair).getReserves();
if (_token < _tokenNext) {
uint256 _balance = _token.balanceOf(_pair);
- uint256 _amountOut = (_reserve1 * (_balance - _reserve0) * 997) / (_balance * 1_000);
+ uint256 amountInWithFee = (_balance - _reserve0) * 997;
+ uint256 _amountOut = (_reserve1 * amountInWithFee) / (_reserve0 * 1_000 + amountInWithFee);
IJoePair(_pair).swap(0, _amountOut, _recipient, "");
} else {
uint256 _balance = _token.balanceOf(_pair);
- uint256 _amountOut = (_reserve0 * (_balance - _reserve1) * 997) / (_balance * 1_000);
+ uint256 amountInWithFee = (_balance - _reserve1) * 997;
+ uint256 _amountOut = (_reserve0 * amountInWithFee) / (_reserve1 * 1_000 + amountInWithFee);
IJoePair(_pair).swap(_amountOut, 0, _recipient, "");
}
These calculations are implemented correctly in JoeLibrary.getAmountOut, which is used in LBQuoter. Also it’s used in Trader Joe V1 to calculate output amounts in similar functions:
// test/audit/RouterMath2.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.7;
import "../TestHelper.sol";
import "../../src/LBRouter.sol";
import "../../src/interfaces/IJoePair.sol";
contract RouterMath2Test is TestHelper {
IERC20 internal token;
uint256 internal actualAmountOut;
function setUp() public {
token = new ERC20MockDecimals(18);
ERC20MockDecimals(address(token)).mint(address(this), 100e18);
router = new LBRouter(
ILBFactory(address(0x00)),
IJoeFactory(address(this)),
IWAVAX(address(0x02))
);
}
// Imitates V1 factory.
function getPair(address, /*tokenX*/ address /*tokenY*/ ) public view returns (address) {
return address(this);
}
// Imitates V1 pool.
function getReserves() public pure returns (uint112, uint112, uint32) {
return (1e18, 1e18, 0);
}
// Imitates V1 pool.
function balanceOf(address /*acc*/) public pure returns (uint256) {
return 0.0001e18;
}
// Imitates V1 pool.
function swap(uint256 amount0, uint256 amount1, address to, bytes memory data) public {
actualAmountOut = amount0 == 0 ? amount1 : amount0;
}
function testScenario() public {
// Setting up a swap via one V1 pool.
uint256[] memory steps = new uint256[](1);
steps[0] = 0;
IERC20[] memory path = new IERC20[](2);
path[0] = IERC20(address(token));
path[1] = IERC20(address(this));
uint256 amountIn = 0.0001e18;
token.approve(address(router), 1e18);
router.swapExactTokensForTokensSupportingFeeOnTransferTokens(
amountIn, 0, steps, path, address(this), block.timestamp + 1000
);
// This amount was calculated incorrectly.
assertEq(actualAmountOut, 987030000000000000); // Equals to 989970211528238869 when fixed.
address _pair = address(this);
uint256 expectedAmountOut;
// Reproduce the calculations using JoeLibrary.getAmountIn. This piece:
(uint256 _reserve0, uint256 _reserve1, ) = IJoePair(_pair).getReserves();
if (address(token) < address(this)) {
uint256 _balance = token.balanceOf(_pair);
expectedAmountOut = JoeLibrary.getAmountOut(_balance - _reserve0, _reserve0, _reserve1);
} else {
uint256 _balance = token.balanceOf(_pair);
expectedAmountOut = JoeLibrary.getAmountOut(_balance - _reserve1, _reserve1, _reserve0);
}
// This is the correct amount.
assertEq(expectedAmountOut, 989970211528238869);
// The wrong amount is smaller than the expected one.
assertEq(expectedAmountOut - actualAmountOut, 2940211528238869);
}
}
Recommended Mitigation Steps
Consider using the JoeLibrary.getAmountOut
function in the _swapSupportingFeeOnTransferTokens
function of LBRouter
when computing output amounts for V1 pools.
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) commented:
The warden has shown how, due to incorrect calculations, swaps routed through V1 functions may cause losses to end users.
Because the issue is with a core mechanism of the protocol, and the warden has shown (via coded POC) how a loss can happen, I agree with High Severity.
While this finding is similar to H-01, at this time I think it’s different enough to keep it separate as the internals and code paths are distinct.
[H-03] Wrong implementation of function LBPair.setFeeParameter
can break the funcionality of LBPair and make user’s tokens locked
Submitted by KIntern_NA, also found by Trust and KingNFT
Struct FeeParameters
contains 12 fields as follows:
struct FeeParameters {
// 144 lowest bits in slot
uint16 binStep;
uint16 baseFactor;
uint16 filterPeriod;
uint16 decayPeriod;
uint16 reductionFactor;
uint24 variableFeeControl;
uint16 protocolShare;
uint24 maxVolatilityAccumulated;
// 112 highest bits in slot
uint24 volatilityAccumulated;
uint24 volatilityReference;
uint24 indexRef;
uint40 time;
}
Function LBPair.setFeeParamters(bytes _packedFeeParamters)
is used to set the first 8 fields which was stored in 144 lowest bits of LBPair._feeParameter
’s slot to 144 lowest bits of _packedFeeParameters
(The layout of _packedFeeParameters
can be seen here).
/// @notice Internal function to set the fee parameters of the pair
/// @param _packedFeeParameters The packed fee parameters
function _setFeesParameters(bytes32 _packedFeeParameters) internal {
bytes32 _feeStorageSlot;
assembly {
_feeStorageSlot := sload(_feeParameters.slot)
}
/// [#explain] it will get 112 highest bits of feeStorageSlot,
/// and stores it in the 112 lowest bits of _varParameters
uint256 _varParameters
= _feeStorageSlot.decode(type(uint112).max, _OFFSET_VARIABLE_FEE_PARAMETERS/*=144*/);
/// [#explain] get 144 lowest bits of packedFeeParameters
/// and stores it in the 144 lowest bits of _newFeeParameters
uint256 _newFeeParameters = _packedFeeParameters.decode(type(uint144).max, 0);
assembly {
// [$audit-high] wrong operation `or` here
// Mitigate: or(_newFeeParameters, _varParameters << 144)
sstore(_feeParameters.slot, or(_newFeeParameters, _varParameters))
}
}
As we can see in the implementation of LBPair._setFeesParametes
above, it gets the 112 highest bits of _feeStorageSlot
and stores it in the 112 lowest bits of _varParameter
. Then it gets the 144 lowest bits of packedFeeParameter
and stores it in the 144 lowest bits of _newFeeParameters
.
Following the purpose of function setFeeParameters
, the new LBPair._feeParameters
should form as follow:
// keep 112 highest bits remain unchanged
// set 144 lowest bits to `_newFeeParameter`
[...112 bits...][....144 bits.....]
[_varParameters][_newFeeParameters]
It will make feeParameters = _newFeeParameters | (_varParameters << 144)
. But current implementation just stores the or
value of _varParameters
and _newFeeParameter
into _feeParameters.slot
. It forgot to shift left the _varParameters
144 bits before executing or
operation.
This will make the value of binStep
, …, maxVolatilityAccumulated
incorrect, and also remove the value (make the bit equal to 0) of volatilityAccumulated
, …, time
.
Impact
- Incorrect fee calculation when executing an action with LBPair (swap, flashLoan, mint)
- Break the functionality of LBPair. The user can’t swap/mint/flashLoan
--> Make all the tokens stuck in the pools
Proof of concept
Here is our test script to describe the impacts
You can place this file into /test
folder and run it using
forge test --match-contract High1Test -vv
Explanation of test script:
- First we create a pair with
binStep = DEFAULT_BIN_STEP = 25
- We do some actions (add liquidity -> mint -> swap) to increase the value of
volatilityAccumulated
from0
to60000
- We call function
factory.setFeeParametersOnPair
to set new fee parameters. - After that the value of
volatilityAccumulated
changed to value0
(It should still be unchanged afterfactory.setFeeParametersOnPair
) -
We check the value of
binStep
and it changed from25
to60025
binStep
has that value because line 915 setbinStep = uint16(volatilityAccumulated) | binStep = 60000 | 25 = 60025
.
- This change of
binStep
value will break all the functionality ofLBPair
causebinStep > Constant.BASIS_POINT_MAX = 10000
-->
Error: BinStepOverflows
Tools Used
Foundry
Recommended Mitigation Steps
Modify function LBPair._setFeesParaters
as follow:
function _setFeesParameters(bytes32 _packedFeeParameters) internal {
bytes32 _feeStorageSlot;
assembly {
_feeStorageSlot := sload(_feeParameters.slot)
}
uint256 _varParameters = _feeStorageSlot.decode(type(uint112).max, _OFFSET_VARIABLE_FEE_PARAMETERS);
uint256 _newFeeParameters = _packedFeeParameters.decode(type(uint144).max, 0);
assembly {
sstore(_feeParameters.slot, or(_newFeeParameters, shl(144, _varParameters)))
}
}
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) commented:
The warden has shown how, due to a missing shift, packed settings for
feeParameters
will be improperly stored, causing undefined behaviour.The mistake can be trivially fixed and the above code offers a test case for remediation.
Because the finding impacts the protocol functionality, despite it’s perceived simplicity, I agree with High Severity as the code is not working as intended in a fundamental way.
[H-04] Wrong calculation in function LBRouter._getAmountsIn
make user lose a lot of tokens when swap through JoePair (most of them will gifted to JoePair freely)
Submitted by KIntern_NA, also found by hansfriese, Jeiwan, and cccz
Function LBRouter._getAmountsIn
is a helper function to return the amounts in with given amountOut
. This function will check the pair of _token
and _tokenNext
is JoePair
or LBPair
using _binStep
.
- If
_binStep == 0
, it will be aJoePair
otherwise it will be anLBPair
.
if (_binStep == 0) {
(uint256 _reserveIn, uint256 _reserveOut, ) = IJoePair(_pair).getReserves();
if (_token > _tokenPath[i]) {
(_reserveIn, _reserveOut) = (_reserveOut, _reserveIn);
}
uint256 amountOut_ = amountsIn[i];
// Legacy uniswap way of rounding
amountsIn[i - 1] = (_reserveIn * amountOut_ * 1_000) / (_reserveOut - amountOut_ * 997) + 1;
} else {
(amountsIn[i - 1], ) = getSwapIn(ILBPair(_pair), amountsIn[i], ILBPair(_pair).tokenX() == _token);
}
As we can see when _binStep == 0
and _token < _tokenPath[i]
(in another word we swap through JoePair
and pair’stoken0
is _token
and token1
is _tokenPath[i]
), it will
- Get the reserve of pair (
reserveIn
,reserveOut
) - Calculate the
_amountIn
by using the formula
amountsIn[i - 1] = (_reserveIn * amountOut_ * 1_000) / (_reserveOut - amountOut_ * 997) + 1
But unfortunately the denominator _reserveOut - amountOut_ * 997
seem incorrect. It should be (_reserveOut - amountOut_) * 997
.
We will do some math calculations here to prove the expression above is wrong.
Input:
_reserveIn (rIn)
: reserve of_token
in pair_reserveOut (rOut)
: reserve of_tokenPath[i]
in pairamountOut_
: the amount of_tokenPath
the user wants to gain
Output:
rAmountIn
: the actual amount of_token
we need to transfer to the pair.
Generate Formula:
Cause JoePair
takes 0.3% of amountIn
as fee, we get
amountInDeductFee = amountIn' * 0.997
Following the constant product formula, we have
rIn * rOut = (rIn + amountInDeductFee) * (rOut - amountOut_)
==> rIn + amountInDeductFee = rIn * rOut / (rOut - amountOut_) + 1
<=> amountInDeductFee = (rIn * rOut) / (rOut - amountOut_) - rIn + 1
<=> rAmountIn * 0.997 = rIn * amountOut / (rOut - amountOut_) + 1
<=> rAmountIn = (rIn * amountOut * 1000) / ((rOut - amountOut_) * 997) + 1
<=>
As we can see rAmountIn
is different from amountsIn[i - 1]
, the denominator of rAmountIn
is (rOut - amountOut_) * 997
when the denominator of amountsIn[i - 1]
is _reserveOut - amountOut_ * 997
(Missing one bracket)
Impact
Loss of fund: User will send a lot of tokenIn (much more than expected) but just gain exact amountOut in return.
Let dive in the function swapTokensForExactTokens()
to figure out why this scenario happens. I will assume I just swap through only one pool from JoePair
and 0 pool from LBPair
.
-
Firstly function will get the list
amountsIn
from function_getAmountsIn
. SoamountsIn
will be [incorrectAmountIn
,userDesireAmount
].amountsIn = _getAmountsIn(_pairBinSteps, _pairs, _tokenPath, _amountOut);
-
Then it transfers
incorrectAmountIn
to_pairs[0]
to prepare for the swap._tokenPath[0].safeTransferFrom(msg.sender, _pairs[0], amountsIn[0]);
-
Finally it calls function
_swapTokensForExactToken
to execute the swap.uint256 _amountOutReal = _swapTokensForExactTokens(_pairs, _pairBinSteps, _tokenPath, amountsIn, _to);
In this step it will reach to line 841 which will set the expected
amountOut = amountsIn[i+1] = amountsIn[1] = userDesireAmount
.amountOut = _amountsIn[i + 1];
So after calling
IJoePair(_pair).swap()
, the user just gets exactlyamountOut
and wastes a lot of tokenIn that (s)he transfers to the pool.
Proof of concept
Here is our test script to describe the impacts
You can place this file into /test
folder and run it using
forge test --match-test testBugSwapJoeV1PairWithLBRouter --fork-url https://rpc.ankr.com/avalanche --fork-block-number 21437560 -vv
Explanation of test script: (For more detail you can read the comments from test script above)
- Firstly we get the Joe v1 pair WAVAX/USDC from JoeFactory.
- At the forked block, price
WAVAX/USDC
was around 15.57. We try to use LBRouter functionswapTokensForExactTokens
to swap10$
WAVAX (10e18 wei) to1$
USDC (1e6 wei). But it reverts with the errorLBRouter__MaxAmountInExceeded
. But when we swap directly to JoePair, it swap successfully10$
AVAX (10e18 wei) to155$
USDC (155e6 wei). - We use LBRouter function
swapTokensForExactTokens
again with very largeamountInMax
to swap1$
USDC (1e6 wei). It swaps successfully but needs to pay a very large amount WAVAX (much more than price).
Tools Used
Foundry
Recommended Mitigation Steps
Modify function LBRouter._getAmountsIn
as follow
if (_binStep == 0) {
(uint256 _reserveIn, uint256 _reserveOut, ) = IJoePair(_pair).getReserves();
if (_token > _tokenPath[i]) {
(_reserveIn, _reserveOut) = (_reserveOut, _reserveIn);
}
uint256 amountOut_ = amountsIn[i];
// Legacy uniswap way of rounding
// Fix here
amountsIn[i - 1] = (_reserveIn * amountOut_ * 1_000) / ((_reserveOut - amountOut_) * 997) + 1;
} else {
(amountsIn[i - 1], ) = getSwapIn(ILBPair(_pair), amountsIn[i], ILBPair(_pair).tokenX() == _token);
}
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) commented:
The warden has shown how, due to an incorrect order of operation, the math for the router will be incorrect.
While the error could be considered a typo, the router is the designated proper way of performing a swap, and due to this finding, the math will be off.
Because the impact shows an incorrect logic, and a broken invariant (the router uses incorrect amounts, sometimes reverting, sometimes costing the end user more tokens than necessary), I believe High Severity to be appropriate.
Mitigation will require refactoring and may be aided by the test case offered in this report.
[H-05] Attacker can steal entire reserves by abusing fee calculation
Submitted by Trust, also found by zzykxx
https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBPair.sol#L819-L829
https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBToken.sol#L202
Similar to other LP pools, In Trader Joe users can call mint()
to provide liquidity and receive LP tokens, and burn()
to return their LP tokens in exchange for underlying assets. Users collect fees using collectFess(account,binID)
. Fees are implemented using debt model. The fundamental fee calculation is:
function _getPendingFees(
Bin memory _bin,
address _account,
uint256 _id,
uint256 _balance
) private view returns (uint256 amountX, uint256 amountY) {
Debts memory _debts = _accruedDebts[_account][_id];
amountX = _bin.accTokenXPerShare.mulShiftRoundDown(_balance, Constants.SCALE_OFFSET) - _debts.debtX;
amountY = _bin.accTokenYPerShare.mulShiftRoundDown(_balance, Constants.SCALE_OFFSET) - _debts.debtY;
}
accTokenXPerShare / accTokenYPerShare is an ever increasing amount that is updated when swap fees are paid to the current active bin.
When liquidity is first minted to user, the _accruedDebts is updated to match current _balance * accToken*PerShare. Without this step, user could collect fees for the entire growth of accToken*PerShare from zero to current value. This is done in _updateUserDebts, called by _cacheFees() which is called by _beforeTokenTransfer(), the token transfer hook triggered on mint/burn/transfer.
function _updateUserDebts(
Bin memory _bin,
address _account,
uint256 _id,
uint256 _balance
) private {
uint256 _debtX = _bin.accTokenXPerShare.mulShiftRoundDown(_balance, Constants.SCALE_OFFSET);
uint256 _debtY = _bin.accTokenYPerShare.mulShiftRoundDown(_balance, Constants.SCALE_OFFSET);
_accruedDebts[_account][_id].debtX = _debtX;
_accruedDebts[_account][_id].debtY = _debtY;
}
The critical problem lies in _beforeTokenTransfer:
if (_from != _to) {
if (_from != address(0) && _from != address(this)) {
uint256 _balanceFrom = balanceOf(_from, _id);
_cacheFees(_bin, _from, _id, _balanceFrom, _balanceFrom - _amount);
}
if (_to != address(0) && _to != address(this)) {
uint256 _balanceTo = balanceOf(_to, _id);
_cacheFees(_bin, _to, _id, _balanceTo, _balanceTo + _amount);
}
}
Note that if _from or _to is the LBPair contract itself, _cacheFees won’t be called on _from or _to respectively. This was presumably done because it is not expected that the LBToken address will receive any fees. It is expected that the LBToken will only hold tokens when user sends LP tokens to burn.
This is where the bug manifests - the LBToken address (and 0 address), will collect freshly minted LP token’s fees from 0 to current accToken*PerShare value.
We can exploit this bug to collect the entire reserve assets. The attack flow is:
- Transfer amount X to pair
- Call
pair.mint()
, with the to address = pair address - call
collectFees()
with pair address as account -> pair will send to itself the fees! It is interesting that both OZ ERC20 implementation and LBToken implementation allow this, otherwise this exploit chain would not work - Pair will now think user sent in money, because the bookkeeping is wrong. _pairInformation.feesX.total is decremented in
collectFees()
, but the balance did not change. Therefore, this calculation will credit attacker with the fees collected into the pool:
uint256 _amountIn = _swapForY
? tokenX.received(_pair.reserveX, _pair.feesX.total)
: tokenY.received(_pair.reserveY, _pair.feesY.total);
- Attacker calls
swap()
and receives reserve assets using the fees collected. - Attacker calls
burn()
, passing their own address in _to parameter. This will successfully burn the minted tokens from step 1 and give Attacker their deposited assets.
Note that if the contract did not have the entire collectFees code in an unchecked block, the loss would be limited to the total fees accrued:
if (amountX != 0) {
_pairInformation.feesX.total -= uint128(amountX);
}
if (amountY != 0) {
_pairInformation.feesY.total -= uint128(amountY);
}
If attacker would try to overflow the feesX/feesY totals, the call would revert. Unfortunately, because of the unchecked block feesX/feesY would overflow and therefore there would be no problem for attacker to take the entire reserves.
Impact
Attacker can steal the entire reserves of the LBPair.
Proof of Concept
Paste this test in LBPair.Fees.t.sol:
function testAttackerStealsReserve() public {
uint256 amountY= 53333333333333331968;
uint256 amountX = 100000;
uint256 amountYInLiquidity = 100e18;
uint256 totalFeesFromGetSwapX;
uint256 totalFeesFromGetSwapY;
addLiquidity(amountYInLiquidity, ID_ONE, 5, 0);
uint256 id;
(,,id ) = pair.getReservesAndId();
console.log("id before" , id);
//swap X -> Y and accrue X fees
(uint256 amountXInForSwap, uint256 feesXFromGetSwap) = router.getSwapIn(pair, amountY, true);
totalFeesFromGetSwapX += feesXFromGetSwap;
token6D.mint(address(pair), amountXInForSwap);
vm.prank(ALICE);
pair.swap(true, DEV);
(uint256 feesXTotal, , uint256 feesXProtocol, ) = pair.getGlobalFees();
(,,id ) = pair.getReservesAndId();
console.log("id after" , id);
console.log("Bob balance:");
console.log(token6D.balanceOf(BOB));
console.log(token18D.balanceOf(BOB));
console.log("-------------");
uint256 amount0In = 100e18;
uint256[] memory _ids = new uint256[](1); _ids[0] = uint256(ID_ONE);
uint256[] memory _distributionX = new uint256[](1); _distributionX[0] = uint256(Constants.PRECISION);
uint256[] memory _distributionY = new uint256[](1); _distributionY[0] = uint256(0);
console.log("Minting for BOB:");
console.log(amount0In);
console.log("-------------");
token6D.mint(address(pair), amount0In);
//token18D.mint(address(pair), amount1In);
pair.mint(_ids, _distributionX, _distributionY, address(pair));
uint256[] memory amounts = new uint256[](1);
console.log("***");
for (uint256 i; i < 1; i++) {
amounts[i] = pair.balanceOf(address(pair), _ids[i]);
console.log(amounts[i]);
}
uint256[] memory profit_ids = new uint256[](1); profit_ids[0] = 8388608;
(uint256 profit_X, uint256 profit_Y) = pair.pendingFees(address(pair), profit_ids);
console.log("profit x", profit_X);
console.log("profit y", profit_Y);
pair.collectFees(address(pair), profit_ids);
(uint256 swap_x, uint256 swap_y) = pair.swap(true,BOB);
console.log("swap x", swap_x);
console.log("swap y", swap_y);
console.log("Bob balance after swap:");
console.log(token6D.balanceOf(BOB));
console.log(token18D.balanceOf(BOB));
console.log("-------------");
console.log("*****");
pair.burn(_ids, amounts, BOB);
console.log("Bob balance after burn:");
console.log(token6D.balanceOf(BOB));
console.log(token18D.balanceOf(BOB));
console.log("-------------");
}
Tools Used
Manual audit, foundry
Recommended Mitigation Steps
Code should not exempt any address from _cacheFees(). Even address(0)
is important, because attacker can collectFees for the 0 address to overflow the FeesX/FeesY variables, even though the fees are not retrievable for them.
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) commented:
The Warden has shown how to exploit logic paths that would skip fee accrual, to be able to gather more fees than expected.
While the finding pertains to a loss of fees, the repeated attack will allow stealing reserves as well, for this reason I agree with High Severity.
Medium Risk Findings (7)
[M-01] LBRouter.removeLiquidity
returning wrong values
Submitted by Lambda
LBRouter.removeLiquidity
reorders tokens when the user did not pass them in the pair order (ascending order):
if (_tokenX != _LBPair.tokenX()) {
(_tokenX, _tokenY) = (_tokenY, _tokenX);
(_amountXMin, _amountYMin) = (_amountYMin, _amountXMin);
}
However, when returning amountX
and amountY
, it is ignored if the order was changed:
(amountX, amountY) = _removeLiquidity(_LBPair, _amountXMin, _amountYMin, _ids, _amounts, _to);
Therefore, when the order of the tokens is swapped by the function, the return value amountX
(“Amount of token X returned”) in reality is the amount of the user-provided token Y that is returned and vice versa.
Because this is an exposed function that third-party protocols / contracts will use, this can cause them to malfunction. For instance, when integrating with Trader Joe, something natural to do is:
(uint256 amountAReceived, uint256 amountBReceived) = LBRouter.removeLiquidity(address(tokenA), address(tokenB), ...);
contractBalanceA += amountAReceived;
contractBalanceB += amountBReceived;
This snippet will only be correct when the token addresses are passed in the right order, which should not be the case. When they are not passed in the right order, the accounting of third-party contracts will be messed up, leading to vulnerabilities / lost funds there.
Proof Of Concept
First consider the following diff, which shows a scenario when LBRouter
does not switch tokenX
and tokenY
, resulting in correct return values:
--- a/test/LBRouter.Liquidity.t.sol
+++ b/test/LBRouter.Liquidity.t.sol
@@ -57,7 +57,9 @@ contract LiquidityBinRouterTest is TestHelper {
pair.setApprovalForAll(address(router), true);
- router.removeLiquidity(
+ uint256 token6BalBef = token6D.balanceOf(DEV);
+ uint256 token18BalBef = token18D.balanceOf(DEV);
+ (uint256 amountFirstRet, uint256 amountSecondRet) = router.removeLiquidity(
token6D,
token18D,
DEFAULT_BIN_STEP,
@@ -70,7 +72,9 @@ contract LiquidityBinRouterTest is TestHelper {
);
assertEq(token6D.balanceOf(DEV), amountXIn);
+ assertEq(amountXIn, token6BalBef + amountFirstRet);
assertEq(token18D.balanceOf(DEV), _amountYIn);
+ assertEq(_amountYIn, token18BalBef + amountSecondRet);
}
function testRemoveLiquidityReverseOrder() public {
This test passes (as it should). Now, consider the following diff, where LBRouter
switches tokenX
and tokenY
:
--- a/test/LBRouter.Liquidity.t.sol
+++ b/test/LBRouter.Liquidity.t.sol
@@ -57,12 +57,14 @@ contract LiquidityBinRouterTest is TestHelper {
pair.setApprovalForAll(address(router), true);
- router.removeLiquidity(
- token6D,
+ uint256 token6BalBef = token6D.balanceOf(DEV);
+ uint256 token18BalBef = token18D.balanceOf(DEV);
+ (uint256 amountFirstRet, uint256 amountSecondRet) = router.removeLiquidity(
token18D,
+ token6D,
DEFAULT_BIN_STEP,
- totalXbalance,
totalYBalance,
+ totalXbalance,
ids,
amounts,
DEV,
@@ -70,7 +72,9 @@ contract LiquidityBinRouterTest is TestHelper {
);
assertEq(token6D.balanceOf(DEV), amountXIn);
+ assertEq(amountXIn, token6BalBef + amountSecondRet);
assertEq(token18D.balanceOf(DEV), _amountYIn);
+ assertEq(_amountYIn, token18BalBef + amountFirstRet);
}
function testRemoveLiquidityReverseOrder() public {
This test should also pass (the order of the tokens was only switched), but it does not because the return values are mixed up.
Recommended Mitigation Steps
Add the following statement in the end:
if (_tokenX != _LBPair.tokenX()) {
return (amountY, amountX);
}
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) commented:
The Warden has shown how, due to an inconsistent re-ordering, the removeLiqudity function can return incorrect (swapped) amounts.
While invariants are not broken, this is an example of an incorrect function behaviour.
For this reason, despite no loss of value, I believe Medium Severity to be appropriate as the potential impact warrants an increased severity.
[M-02] beforeTokenTransfer
called with wrong parameters in LBToken._burn
Submitted by Lambda, also found by imare, indijanc, MiloTruck, zzzitron, chaduke, bctester, Aymen0909, The_GUILD, RustyRabbit, phaze, 0x52, ladboy233, and KingNFT
In LBToken._burn
, the _beforeTokenTransfer
hook is called with from = address(0)
and to = _account
:
_beforeTokenTransfer(address(0), _account, _id, _amount);
Through a lucky coincidence, it turns out that this in the current setup does not cause a high severity issue. _burn
is always called with _account = address(this)
, which means that LBPair._beforeTokenTransfer
is a NOP. However, this wrong call is very dangerous for future extensions or protocol that built on top of the protocol / fork it.
Proof Of Concept
Let’s say the protocol is extended with some logic that needs to track mints / burns. The canonical way to do this would be:
function _beforeTokenTransfer(
address _from,
address _to,
uint256 _id,
uint256 _amount
) internal override(LBToken) {
if (_from == address(0)) {
// Mint Logic
} else if (_to == address(0)) {
// Burn Logic
}
}
Such an extension would break, which could lead to loss of funds or a bricked system.
Recommended Mitigation Steps
Call the hook correctly:
_beforeTokenTransfer(_account, address(0), _id, _amount);
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) commented:
The warden has shown how, due to a typo / programming mistake, the hook for burning tokens is called with incorrect parameters.
Because of the caller being the pair, this ends up having reduced impact.
[M-03] Flashloan fee collection mechanism can be easily manipulated
Submitted by shung, also found by philogy, Trust, parashar, 0x52, rvierdiiev, and KingNFT
LBPair.flashLoan()
utilizes an “unfair” fee mechanism in which the whole pair liquidity can be loaned but only the liquidity providers of the active bin receive the fees. Although one can argue that this unfair structure is to incentivize greater liquidity around the active price range, it nonetheless opens up a way to easily manipulate fees. The current structure allows a user to provide liquidity to an active bin right before a flashloan to receive most of the fees. This trick can be used both by the borrower themselves, or by a third party miner or a node operator frontrunning the flashloan transactions. In either case, this is in detriment to the liquidity providers, who would be providing the bulk of the flashloan, but receiving a much less fraction of the fees.
Proof of Concept
LBPair.flashLoan()
function enables borrowing the entire balance of a pair.
tokenX.safeTransfer(_to, _amountXOut);
tokenY.safeTransfer(_to, _amountYOut);
This means that a liquidity provider’s tokens can be used regardless of which bin their liquidity is in. However, the loan fee is only paid to the active bin’s liquidity providers.
_bins[_id].accTokenXPerShare += _feesX.getTokenPerShare(_totalSupply);
_bins[_id].accTokenYPerShare += _feesY.getTokenPerShare(_totalSupply);
Based on this, someone can frontrun a flashloan by adding liquidity to the active bin to receive the most of the flashloan fees, even if the active bin constitutes a small percentage of the loaned amount. Alternatively, a borrower can also atomically add and remove liquidity to the active bin before and after the flashloan, respectively. This way the borrower essentially would be using flashloans with fraction of the intended fee.
Example Scenario
Imagine a highly volatile market, in which the liquidity providers lag behind the price action, resulting in active bin to constitute a very small percent of the liquidity. The following snippet shows the liquidity in each bin of the eleven bins of this imaginary market. The active bin, marked with an in-line comment, has 1 token X and 1 token Y in its reserves. You can appreciate that such a liquidity composition is highly probable when the price of the asset increases rapidly, leaving the concentrated liquidity behind. Even when this type of a distribution is not readily available, the price can be manipulated to create a similar distribution allowing the profitable execution of the described trick to steal flashloan fees.
[
[ 0, 10 ],
[ 0, 30 ],
[ 0, 40 ],
[ 0, 90 ],
[ 0, 80 ],
[ 0, 50 ],
[ 0, 30 ],
[ 0, 10 ],
[ 0, 9 ],
[ 1, 1 ], // Active bin
[ 1, 0 ]
]
Here, a flashloan user can do the following transactions atomically (note: function arguments are simplified to portray the idea):
LBRouter.addLiquidity({
binId: ACTIVE_BIN,
tokenXAmount: 9,
tokenYAmount: 9
});
LBPair.flashLoan({
tokenXAmount: 359,
tokenYAmount: 0
});
LBRouter.removeLiquidity({
binId: ACTIVE_BIN,
tokenXAmount: 9,
tokenYAmount: 9
});
With this method, the borrower will receive 90% of the liquidity provider fees of the flashloan, even though they only had 2.5% of the token X liquidity. This method is very likely to cause majority of the flashloan fees leaking out of the protocol, denying liquidity providers their revenue. Even if the average flashloan borrower does not utilize this strategy to reduce the effective fee they pay, the trick would surely be used by MEV users sandwiching the flashloan transactions to receive the majority of the fees.
Recommended Mitigation Steps
The ideal remediation would be to have a separate global fee structure for a given pair, and record token X and token Y fees per share separately. So if user A has only token X, they should only receive fee when token X is loaned, and not when token Y is loaned. But user A should receive their fee regardless of which bin their liquidity is in. However, given that users’ token reserves change dynamically, there appears to be no simple way of achieving this.
If the ideal remediation cannot be achieved, a compromise would be to distribute the flashloan fees from the -nth populated bin to the +nth populated bin, where n
is respective to the active bin. n
could be an adjustable market parameter. A higher value of n
would make the flashloan gas cost higher, but it would reduce the feasibility of the issues described in this finding. Admittedly, this is not a perfect solution: As long as an incomplete subset of liquidity providers or bins receive the flashloan fees, there will be bias hence a risk of inequitable or exploitable fee distribution.
If the compromise is not deemed sufficient, the alternative would be to remove the flashloan feature from the protocol altogether. If the liquidity providers cannot equitably receive their flashloan fees, it might not worth to expose them to the risks of flashloans.
0x0Louis (Trader Joe) acknowledged and commented:
We acknowledge this issue as we want to keep the flash loan, but giving fees to the nth bins would make the function too expensive to use. As our goal is to concentrate the liquidity in the active bin, this behavior is fine for us.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
Per the discussion above, the Warden has shown how, LPs that are not in active positions will not receive fees for the liquidity they supplied.
The sponsor acknowledges.
Because the finding demonstrates loss of yield for LPs (inactive liquidity can be used, but will receive no fees), I believe Medium Severity to be appropriate.
The other side of the coin for this feature is that it does incentivize keeping liquidity in an active bin, which does help with capital efficiency, however, at this time, we cannot speculate about the usage of the protocol and per the above some LP risk not receiving fees.
[M-04] Very critical Owner
privileges can cause complete destruction of the project in a possible privateKey exploit
Submitted by 0xSmartContract, also found by csanuragjain, djxploit, hansfriese, Josiah, leosathya, M4TZ1P, sorrynotsorry, wagmi, zzykxx, Aymen0909, chaduke, SooYa, Mukund, pashov, Dravee, catchup, rvierdiiev, Nyx, vv7, cccz, ladboy233, and supernova
Typically, the contract’s owner is the account that deploys the contract. As a result, the owner is able to perform certain privileged activities.
However, Owner privileges are numerous and there is no timelock structure in the process of using these privileges. The Owner is assumed to be an EOA, since the documents do not provide information on whether the Owner will be a multisign structure.
In parallel with the private key thefts of the project owners, which have increased recently, this vulnerability has been stated as medium.
Similar vulnerability;
Private keys stolen:
Hackers have stolen cryptocurrency worth around €552
million from a blockchain project linked to the popular online game Axie Infinity, in one of the largest cryptocurrency heists on record. Security issue : PrivateKey of the project officer was stolen:
https://www.euronews.com/next/2022/03/30/blockchain-network-ronin-hit-by-552-million-crypto-heist
Proof of Concept
onlyOwner
powers;
14 results - 2 files
src/LBFactory.sol:
220: function setLBPairImplementation(address _LBPairImplementation) external override onlyOwner {
322: function setLBPairIgnored() external override onlyOwner {
355: function setPreset() external override onlyOwner {
401: function removePreset(uint16 _binStep) external override onlyOwner {
439: function setFeesParametersOnPair) external override onlyOwner {
473: function setFeeRecipient(address _feeRecipient) external override onlyOwner {
479: function setFlashLoanFee(uint256 _flashLoanFee) external override onlyOwner {
490: function setFactoryLockedState(bool _locked) external override onlyOwner {
498: function addQuoteAsset(IERC20 _quoteAsset) external override onlyOwner {
507: function removeQuoteAsset(IERC20 _quoteAsset) external override onlyOwner {
525: function forceDecay(ILBPair _LBPair) external override onlyOwner {
src/libraries/PendingOwnable.sol:
59: function setPendingOwner(address pendingOwner_) public override onlyOwner {
68: function revokePendingOwner() public override onlyOwner {
84: function renounceOwnership() public override onlyOwner {
Recommended Mitigation Steps
1- A timelock contract should be added to use onlyOwner
privileges. In this way, users can be warned in case of a possible security weakness.
2- onlyOwner
can be a Multisign wallet and this part is specified in the documentation.
0x0Louis (Trader Joe) acknowledged and commented:
The owner will be our multisig.
Alex the Entreprenerd (judge) commented:
Per the rulebook will bulk all Admin Privilege findings under this one.
Most notably the main issue was the lack of validation on the flashloan fee, which this issue acts as an umbrella for.
[M-05] Attacker can keep fees max at no cost
Submitted by Trust, also found by shung and immeas
The volatile fee component in TJ is calculated using several variables, as described here. Importantly, Va (volatility accumulator) = Vr (volatility reference) + binDelta:
$v_a(k) = v_r + |i_r - (activeId + k)|$
Vr is calculated depending on time passed since last swap:
v_r = \left{\begin{matrix}
v_r, & t\<t_f \\
R \cdot v_a & t_f <= t < t_d \\
0, & t_d <= t
\end{matrix}\right.
Below is the implementation:
function updateVariableFeeParameters(FeeParameters memory _fp, uint256 _activeId) internal view {
uint256 _deltaT = block.timestamp - _fp.time;
if (_deltaT >= _fp.filterPeriod || _fp.time == 0) {
_fp.indexRef = uint24(_activeId);
if (_deltaT < _fp.decayPeriod) {
unchecked {
// This can't overflow as `reductionFactor <= BASIS_POINT_MAX`
_fp.volatilityReference = uint24(
(uint256(_fp.reductionFactor) * _fp.volatilityAccumulated) / Constants.BASIS_POINT_MAX
);
}
} else {
_fp.volatilityReference = 0;
}
}
_fp.time = (block.timestamp).safe40();
updateVolatilityAccumulated(_fp, _activeId);
}
The critical issue is that when the time since last swap is below filterPeriod, Vr does not change, yet the last swap timestamp (_fp.time) is updated. Therefore, attacker (TJ competitor) can keep fees extremely high at basically 0 cost, by swapping just under every Tf seconds, a zero-ish amount. Since Vr will forever stay the same, the calculated Va will stay high (at least Vr) and will make the protocol completely uncompetitive around the clock.
The total daily cost to the attacker would be (TX fee (around $0.05
on AVAX) + swap fee (˜0) ) * filterPeriodsInDay (default value is 1728) = $87
.
Impact
Attacker can make any TraderJoe pair uncompetitive at negligible cost.
Proof of Concept
Add this test in LBPair.Fees.t.sol:
function testAbuseHighFeesAttack() public {
uint256 amountY = 30e18;
uint256 id;
uint256 reserveX;
uint256 reserveY;
uint256 amountXInForSwap;
uint256 amountYInLiquidity = 100e18;
FeeHelper.FeeParameters memory feeParams;
addLiquidity(amountYInLiquidity, ID_ONE, 2501, 0);
//swap X -> Y and accrue X fees
(amountXInForSwap,) = router.getSwapIn(pair, amountY, true);
(reserveX,reserveY,id ) = pair.getReservesAndId();
feeParams = pair.feeParameters();
console.log("indexRef - start" , feeParams.indexRef);
console.log("volatilityReference - start" , feeParams.volatilityReference);
console.log("volatilityAccumulated - start" , feeParams.volatilityAccumulated);
console.log("active ID - start" , id);
console.log("reserveX - start" , reserveX);
console.log("reserveY - start" , reserveY);
// ATTACK step 1 - Cross many bins / wait for high volatility period
token6D.mint(address(pair), amountXInForSwap);
vm.prank(ALICE);
pair.swap(true, DEV);
(reserveX,reserveY,id ) = pair.getReservesAndId();
feeParams = pair.feeParameters();
console.log("indexRef - swap1" , feeParams.indexRef);
console.log("volatilityReference - swap1" , feeParams.volatilityReference);
console.log("volatilityAccumulated - swap1" , feeParams.volatilityAccumulated);
console.log("active ID - swap1" , id);
console.log("reserveX - swap1" , reserveX);
console.log("reserveY - swap1" , reserveY);
// ATTACK step 2 - Decay the Va into Vr
vm.warp(block.timestamp + 99);
token18D.mint(address(pair), 10);
vm.prank(ALICE);
pair.swap(false, DEV);
(reserveX,reserveY,id ) = pair.getReservesAndId();
console.log("active ID - swap2" , id);
console.log("reserveX - swap2" , reserveX);
console.log("reserveY - swap2" , reserveY);
feeParams = pair.feeParameters();
console.log("indexRef - swap2" , feeParams.indexRef);
console.log("volatilityReference - swap2" , feeParams.volatilityReference);
console.log("volatilityAccumulated - swap2" , feeParams.volatilityAccumulated);
// ATTACK step 3 - keep high Vr -> high Va
for(uint256 i=0;i<10;i++) {
vm.warp(block.timestamp + 49);
token18D.mint(address(pair), 10);
vm.prank(ALICE);
pair.swap(false, DEV);
(reserveX,reserveY,id ) = pair.getReservesAndId();
console.log("**************");
console.log("ITERATION ", i);
console.log("active ID" , id);
console.log("reserveX" , reserveX);
console.log("reserveY" , reserveY);
feeParams = pair.feeParameters();
console.log("indexRef" , feeParams.indexRef);
console.log("volatilityReference" , feeParams.volatilityReference);
console.log("volatilityAccumulated" , feeParams.volatilityAccumulated);
console.log("**************");
}
}
Tools Used
Manual audit, foundry
Recommended Mitigation Steps
Several options:
- Decay linearly to the time since last swap when T < Tf.
- Don’t update _tf.time if swap did not affect Vr
- If
T<Tf
, only skip Vr update if swap amount is not negligible. This will make the attack not worth it, as protocol will accrue enough fees to offset the lack of user activity.
Severity level
I argue for HIGH severity because I believe the impact to the protocol is that most users will favor alternative AMMs, which directly translates to a large loss of revenue. AMM is known to be a very competitive market and using high volatility fee %
in low volatility times will not attract any users.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
Not sure about severity at this time, changing to group.
Ultimately if invariant broken, High is appropriate, if Loss of Value Med more appropriate. Devil is in the detail.
0x0Louis (Trader Joe) acknowledged and commented:
We acknowledge this issue, but we now reset the
indexRef
in theforceDecay
function.
Alex the Entreprenerd (judge) commented:
The warden has shown how trivially a dust trade can be performed to keep fees higher than intended.
Given the discussion above, considering that fees are more in line with loss of yield / capital inefficiency, I believe the finding to be of Medium Severity.
[M-06] Calling swapAVAXForExactTokens
function while sending excess amount cannot refund such excess amount
Submitted by rbserver, also found by lukris02, Trust, hyh, pashov, TomJ, ElKu, m_Rassska, neumo, d3e4, Rahoz, 8olidity, cccz, and vv7
When calling the swapAVAXForExactTokens
function, if (msg.value > amountsIn[0]) _safeTransferAVAX(_to, amountsIn[0] - msg.value)
is executed, which is for refunding any excess amount sent in; this is confirmed by this function’s comment as well. However, executing amountsIn[0] - msg.value
will always revert when msg.value > amountsIn[0]
is true. Developers who has the design of the swapAVAXForExactTokens
function in mind could develop front-ends and contracts that will send excess amount when calling the swapAVAXForExactTokens
function. Hence, the users, who rely on these front-ends and contracts for interacting with the swapAVAXForExactTokens
function will always find such interactions being failed since calling this function with the excess amount will always revert. As a result, the user experience becomes degraded, and the usability of the protocol becomes limited.
https://github.com/code-423n4/2022-10-traderjoe/blob/main/src/LBRouter.sol#L485-L521
/// @notice Swaps AVAX for exact tokens while performing safety checks
/// @dev will refund any excess sent
...
function swapAVAXForExactTokens(
uint256 _amountOut,
uint256[] memory _pairBinSteps,
IERC20[] memory _tokenPath,
address _to,
uint256 _deadline
)
external
payable
override
ensure(_deadline)
verifyInputs(_pairBinSteps, _tokenPath)
returns (uint256[] memory amountsIn)
{
...
if (msg.value > amountsIn[0]) _safeTransferAVAX(_to, amountsIn[0] - msg.value);
}
Proof of Concept
Please add the following test in test\LBRouter.Swaps.t.sol
. This test will pass to demonstrate the described scenario.
function testSwapAVAXForExactTokensIsUnableToRefund() public {
uint256 amountOut = 1e18;
(uint256 amountIn, ) = router.getSwapIn(pairWavax, amountOut, false);
IERC20[] memory tokenList = new IERC20[](2);
tokenList[0] = wavax;
tokenList[1] = token6D;
uint256[] memory pairVersions = new uint256[](1);
pairVersions[0] = DEFAULT_BIN_STEP;
vm.deal(DEV, amountIn + 500);
// Although the swapAVAXForExactTokens function supposes to refund any excess sent,
// calling it reverts when sending more than amountIn
// because executing _safeTransferAVAX(_to, amountsIn[0] - msg.value) results in arithmetic underflow
vm.expectRevert(stdError.arithmeticError);
router.swapAVAXForExactTokens{value: amountIn + 1}(amountOut, pairVersions, tokenList, DEV, block.timestamp);
}
Tools Used
VSCode
Recommended Mitigation Steps
https://github.com/code-423n4/2022-10-traderjoe/blob/main/src/LBRouter.sol#L520 can be updated to the following code.
if (msg.value > amountsIn[0]) _safeTransferAVAX(_to, msg.value - amountsIn[0]);
0x0Louis (Trader Joe) confirmed, but disagreed with severity
Alex the Entreprenerd (judge) decreased severity to Low and commented:
The finding is valid, sending more than necessary will revert.
No loss to end-users was shown, and the revert will prevent the tx from finishing.Because of that, considering the fact that a accurate measure could be calculated, meaning that functionality can be side-stepped, despite recommending fixing, I think the finding is QA - Low Severity as the revert is self-inflicted.
Alex the Entreprenerd (judge) increased severity to Medium and commented:
Per discussion from backstage I’ve re-reviewed the finding.
I have poked around with the test provided and tweaked it a little, this is the last one I have
function testSwapAVAXForExactTokensIsUnableToRefund() public { uint256 amountOut = 1e18; (uint256 amountIn, ) = router.getSwapIn(pairWavax, amountOut, false); IERC20[] memory tokenList = new IERC20[](2); tokenList[0] = wavax; tokenList[1] = token6D; uint256[] memory pairVersions = new uint256[](1); pairVersions[0] = DEFAULT_BIN_STEP; vm.deal(DEV, 50 * amountIn + 500); // Although the swapAVAXForExactTokens function supposes to refund any excess sent, // calling it reverts when sending more than amountIn // because executing _safeTransferAVAX(_to, amountsIn[0] - msg.value) results in arithmetic underflow vm.expectRevert(stdError.arithmeticError); router.swapAVAXForExactTokens{value: amountIn + 1}(amountOut, pairVersions, tokenList, DEV, block.timestamp); router.swapAVAXForExactTokens{value: amountIn}(amountOut, pairVersions, tokenList, DEV, block.timestamp); vm.expectRevert(stdError.arithmeticError); router.swapAVAXForExactTokens{value: amountIn + 100000}(amountOut, pairVersions, tokenList, DEV, block.timestamp); }
Ultimately we can conclude that any amount below
amountIn
will result in a revert due to insufficient amountOut, and any amount aboveamountIn
will revert due to the highlighted overflow.The function is a routing function, which is meant to allow some degree of slippage.
My original downgrade was based on the idea that the function can run, however, after it being flagged, I must agree that the availability of the function is impaired in the majority of ordinary cases (a
.10%
/.5%
slippage is expected under most operations / FEs).For this reason, I agree with upgrading back to Medium Severity.
I’d like to thank @shung and @hyh for their feedback.
[M-07] Incorrect fee calculation on LBPair (fees collected on swaps are less than what they “should” be)
Submitted by sha256yan, also found by Jeiwan
LBPair contracts consistently collect less fees than their FeeParameters.
Github and source code
https://github.com/sha256yan/incorrect-fee
Motivation and Severity
Note: please see warden’s original submission for full details.
LBpair contracts’ fees fall short by 0.1%
on single bin with the deficit growing exponentially with multi-bin swaps.
This report will refer to this difference in fees, that is, the difference between the expected fees and the actual collected fees as the “Fee Deficit”.
The exponential growth of the Fee Deficit percentage is concerning, considering that the vast majority of the fees collected
by LPs and DEXs are during high volatility periods.
Note that the peak Fee Deficit percentage of 1.6%
means that 1.6%
of expected fees would not be collected.
With an assumed average total fee of 1%
(higher than usual due to variableFee
component) and average Fee Deficit percentage of 0.4%
;
The total Fee Deficit from a period similar to May 7th 2022 - May 14th 2022, with approximately $1.979B
in trading volume, would be $79,160
over one week.
SwapHelper.getAmounts carries most of the blame for this error.
3 main causes have been identified and will be discussed in this report.
- Incorrect use of getFeeAmountFrom
- Incorrect conditional for amountIn overflow
- Need for an additional FeeHelper function
Affected contracts and libraries
-
LBPair.sol
-
LBRouter.sol
-
SwapHelper.sol
Proposed changes
-
FeeHelper.sol
- getAmountInWithFees ( New )
-
SwapHelper.sol
- getAmountsV2 ( New )
-
LBRouter.sol
- getSwapIn ( Modified )
- getSwapOut ( Modified )
-
LBPair.sol
- swap ( Modified )
Details
- As mentioned earlier, most issues arise from
SwapHelper.getAmounts
. The SwapHelper library is often used for the Bin type. (Example in LBPair). The proposed solution includes the new functions SwapHelper.getAmountsV2 and FeeHelper.getAmountInWithFees. - LBPair.swap uses
_bin.getAmounts(...)
on the active bin to calculate fees. (See here) - Inside of
SwapHelper.getAmounts
, for a given swap, if a bin has enough liqudity, the fee is calculated using (FeeHelper.getFeeAmountFrom). This results in smaller than expected fees. LBRouter.getSwapOut
relies onSwapHelper.getAmounts
to simulate swaps. Its simulations adjust to the correct fee upon usingSwapHelper.getAmountsV2
(LBRouter.getSwapOut, SwapHelper.getAmounts, SwapHelper.getAmountsV2)LBRouter.getSwapIn
has a fee calculation error which is independent ofSwapHelper.getAmounts
. (See here)- As of right now the
LBPair.swap
using getAmountsV2 uses3.8%
more gas.
Incorrect use of getFeeAmountFrom
- When there is enough liquidity in a bin for a swap, we should use
FeeHelper.getFeeAmount(amountIn)
instead ofFeeHelper.getFeeAmountFrom(amountIn)
.
Evidence
- amountIn, the parameter passed to calculate fees, is the amount of tokens in the LBPair contract in excess of the reserves and fees of the pair for that token. Inside LBPair.sol - Inside TokenHelper
Will now use example numbers:
- Let amountIn = 1e10 (meaning the user has transferred/minted 1e10 tokens to the LBPair)
- Let PRECISION = 1e18
- Let totalFee = 0.00125 x precision (fee of 0.0125%)
- Let price = 1 (parity)
- If the current bin has enough liqudity, feeAmount must be: (amountIn * totalFee ) / (PRECISION) = 12500000
- FeeHelper.getFeeAmountFrom(amountIn) uses the formula: feeAmount = (amountIn * totalFee) / (PRECISION + totalFee) = 12484394
- FeeHelper.getFeeAmount(amountIn) uses exactly the formula ourlined in the correct feeAmount calculation and is the correct method in this case.
- Visit the tests section to run a test.
Incorrect condition for amountIn overflow
- The condition for when an amountIn overflows the maximum amount available in a bin is flawed.
- The Fee Deficit here could potentially trigger an unnecessary bin de-activation.
Evidence
Snippet 1 (SwapHelper.getAmounts)
fees = fp.getFeeAmountDistribution(fp.getFeeAmount(_maxAmountInToBin));
if (_maxAmountInToBin + fees.total <= amountIn) {
//do things
}
- Collecting the fees on
_maxAmountInToBin
before doing so onamountIn
means we are not checking to see whetheramountIn
after
Consider the following:
Snippet 2 (SwapHelper.getAmountsV2)
fees = fp.getFeeAmountDistribution(fp.getFeeAmount(amountIn));
if (_maxAmountInToBin < amountIn - fees.total) {
//do things
}
- Now, the fees are collected on
amountIn
. - Assuming both conditions are true, the fees from Snippet2 will be necessarily larger than those in Snippet1 since in both cases
_maxAmountInToBin < amountIn
. - Snippet 1 produces false positives. Meaning, SwapHelper.getAmounts changes its active bin id more than needed. (See Tests section at the bottom for the relevant test)
Need for an additional FeeHelper function
- There are currently functions to answer the following question: How many tokens must a user send, to end up with a given amountInToBin after fees, before the swap itself takes place?
Evidence
-
LBRouter.getSwapIn(, amountOut, )
needs this question answered. At a given price, how many tokens must a user send, to receiveamountOut
?- We use the
amountOut
and price to work backwards to theamountInToBin
. - Current approach calculates fees on
amountInToBin
. (See here) - This is incorrect as fees should be calculated on
amountIn
. (As we discussed in Incorrect use of getFeeAmountFrom)
- We use the
- SwapHelper.getAmounts needs to know what hypothetical
amountIn
would end up asmaxAmountInToBin
after fees. This is needed to be able to avoid Incorrect amountIn overflow
Install dependencies
To install dependencies, run the following to install dependencies:
forge install
Tests
To run tests, run the following command:
forge test --match-contract Report -vv
testSingleBinSwapFeeDifference:
- Simple test to show the Fee Defecit in it’s most basic form.
testFalsePositiveBinDeactivation
- Test that shows false positive resulting from the Incorrect condition
testCorrectFeeBinDeactivation
- Test that shows with getAmountsV2 the false positive issue is resolved.
testMultiBinGrowth
- Generates datapoints used in opening graph.
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
The warden has shown an incorrect application of the fee formula, which results in an exponentially growing reduction in fees taken.
While the report is thorough, the maximum impact is a loss of up to
2%
of the total fees (98%
of fees are collected).Because of the reduced order of magnitude for the impact, I think the appropriate severity to be Medium as the finding will cause a loss of yield as shown by the Warden.
Low Risk and Non-Critical Issues
For this contest, 11 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by zzykxx received the top score from the judge.
The following wardens also submitted reports: brgltd, rbserver, hansfriese, pashov, IllIllI, adriro, 0xSmartContract, KingNFT, 0x1f8b, and Rolezn.
Summary
[L-01] Missing sanity checks on to
addresses in LBRouter.sol
[L-02] Potential loss of funds on tokens with big supplies
[L-03] In TokenHelper.sol
the safeTransfer
function does not check for potentially self-destroyed tokens
[L-04] Excess amount returned to flashloan is not sent back
[L-05] It’s possible to pay a lower fee than expected for a flashloan
[L-06] Rebasing tokens are not compatible with the protocol
[L-01] Missing sanity checks on to
addresses in LBRouter.sol
All the public/external functions in LBRouter.sol
require an address to
as a parameter to which to send either tokens, LBtokens or ETH.
When tokens or LBtokens are sent the protocol should check that if the to
address is contract then that contract should is able to manage ERC20/LBTokens
, otherwise funds would be lost.
[L-02] Potential loss of funds on tokens with big supplies
swap()
and mint()
both reverts if either 2^112
or 2^128
tokens are sent to the pair. This would result in the funds being stuck and nobody being able to mint or swap. Submitting as low because the cost of attack is extremely high, but it’s good to be aware of it.
[L-03] In TokenHelper.sol
the safeTransfer
function does not check for potentially self-destroyed tokens.
If a pair gets created and after a while one of the tokens gets self-destroyed (maybe because of a bug) then safeTransfer
would still succeed. It’s probably a good idea to check if the contract still exists by checking the bytecode length.
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) commented:
Really high impact, short and sweet when adding together all findings, good job!
L-01 Missing sanity checks on to addresses in LBRouter.sol
L-02 Potential loss of funds on tokens with big supplies
L-03 In TokenHelper.sol the safeTransfer function does not check for potentially self-destroyed tokens.
Also included for judging:
L-04 Excess amount returned to flashloan is not sent back
L-05 It’s possible to pay a lower fee than expected for a flashloan
L-06 Rebasing tokens are not compatible with the protocol
Gas Optimizations
For this contest, 14 reports were submitted by wardens detailing gas optimizations. The report highlighted below by shung received the top score from the judge.
The following wardens also submitted reports: c3phas, 0x4non, MiloTruck, pfapostol, IllIllI, TomJ, __141345__, m_Rassska, ReyAdmirado, Shishigami, Saintcode_, Mathieu, and LeoS.
[G-01] Owner token enumeration is an extremely expensive operation but it is not essential to the protocol
LBToken
enumerates token/bin IDs owned by users in a pair. The enumeration is only exposed through two external functions, which are just for convenience for off-chain usage, and not necessary for the functionality of the protocol. Removing enumeration will save tremendous amounts of gas during essential operations of adding and removing liquidity.
Impact of enumeration
OpenZeppelin’s EnumerableSet roughly costs 50,000 gas when adding and removing elements from the set. Even for a small price range, adding liquidity in Liquidity Book requires minting tokens from many bins. For example, currently the testnet user interface mints 31 tokens when adding liquidity in a normal distribution shape. This operation roughly costs 4,000,000 gas, and removal costs about half of that. Given a volatile market, we can expect users to remove and re-add liquidity pretty often. This coupled operation costs around 6,000,000 gas if you have the minimum amount of bins in a normal distribution (31 as allowed by the current UI), which will be about 0.15 AVAX (25 nAVAX base fee). That would be $2.25
in current AVAX price ($15
). And that would be $15
when AVAX is $100
, and $120
when AVAX is $100
and network is heavily used (200 nAVAX base fee). Given that the protocol needs swap_fee_earned / gas_fee_to_move_liquidity
to be greater than 1
to incentivize users to chase the price to concentrate the liquidity, the mint & burn fees must be as little as possible to allow non-whales to be also able to profitably move around their liquidity. Removing the enumeration can nearly halve that cost, making the protocol enticing to more users.
[Non-]reasons to enumerate
Enumeration allows user interfaces to easily see which bins a user is in directly from the blockchain. With the absence of enumeration, Trader Joe will need to index this information either using in-house tools or using something like The Graph. Trader Joe team is already familiar with indexing through their NFT marketplace Joepegs, therefore it seems practical for them to go off-chain indexing route.
Enumeration allows a decentralized way to pull the information from the blockchain. We have to admit that not enumerating would be in detriment to user interfaces that would have wanted to integrate Liquidity Book by using decentralized methods only. However, that is a very small percent of builders that hold such principles. The rest of the builders can also use off-chain indexing.
There is also the end user who might want to learn which bins they are in conveniently using decentralized methods. They can still do this in decentralized manner by checking all the bins, given the bin IDs are determined by step and price and have a range of few thousand (bin step = 100) to few millions (bin step = 1). Admittedly this is not very convenient, but it is doable.
Diff to remove enumeration
For diff that removes the enumeration from the code and tests, see warden’s original submission.
Gas savings
Below is the output of forge snapshot --diff | tail -65
converted to CSV. Especially see testInternalBurn
and testInternalMint
functions showing greater than 50%
savings.
Test Function,Gas Cost Difference,Percent Difference
testSetLBPairImplementation(),-460002,-2.742%
testConstructor(uint16,uint16,uint16,uint16,uint16,uint24,uint16,uint24),-153429,-3.014%
testGetSwapInOverflowReverts(),-67031,-9.004%
testGetSwapOutWithMultipleChoices(),-427507,-10.436%
testOracleSampleFromWith2Samples(),-67031,-12.911%
testSwapYtoXSingleBinFromGetSwapIn(),-66966,-13.118%
testSwapYtoXSingleBinFromGetSwapOut(),-67031,-13.449%
testSwapXtoYSingleBinFromGetSwapOut(),-67031,-13.450%
testSwapXtoYSingleBinFromGetSwapIn(),-67031,-13.501%
testOracleSampleFromEdgeCases(),-67009,-13.970%
testFuzzingAddLiquidity(uint256),-157150,-15.139%
testDistributionOverflowReverts(),-134018,-15.454%
testOracleSampleFromWith100Samples(),-4487757,-17.917%
testClaimFeesComplex(uint256,uint256),-247423,-18.921%
testForIdSlippageCaughtReverts(),-427485,-19.194%
testClaimProtocolFees(),-247401,-19.862%
testClaimFeesY(),-247335,-20.163%
testClaimFeesX(),-247335,-20.163%
testFeesOnTokenTransfer(),-284146,-20.361%
testSwapWithDifferentBinSteps(),-427529,-20.375%
testForAmountSlippageCaughtReverts(),-473364,-21.279%
testGetSwapInWrongAmountsReverts(),-427485,-21.326%
testFlawedCompositionFactor(),-359365,-21.362%
testGetSwapInMoreBins(),-427031,-21.706%
testInsufficientLiquidityMinted(),-359321,-21.806%
testGetSwapOutOnComplexRoute(),-427464,-22.719%
testGetSwapInOnComplexRoute(),-427507,-22.968%
testOracleSampleFromWith100SamplesNotAllInitialized(),-4484457,-22.991%
testAddLiquidityIgnored(),-428376,-23.297%
testGetSwapInWithMultipleChoices(),-427507,-23.756%
testSwapYtoXDistantBinsFromGetSwapOut(),-427421,-23.911%
testSwapYtoXDistantBinsFromGetSwapIn(),-427421,-23.933%
testBalanceOfBatch(),-256383,-24.074%
testFeeOnActiveBinReverse(),-213936,-24.331%
testFeeOnActiveBin(),-213936,-24.331%
testSwapXtoYDistantBinsFromGetSwapOut(),-427486,-24.412%
testSafeBatchTransferNotApprovedReverts(),-256343,-24.420%
testSwapXtoYDistantBinsFromGetSwapIn(),-427486,-24.425%
testSafeTransferNotApprovedReverts(),-256376,-24.488%
testFlashloan(),-427513,-24.826%
testSwapXtoYConsecutiveBinFromGetSwapOut(),-427486,-25.050%
testSwapXtoYConsecutiveBinFromGetSwapIn(),-427486,-25.064%
testSwapYtoXConsecutiveBinFromGetSwapOut(),-427486,-25.089%
testSwapYtoXConsecutiveBinFromGetSwapIn(),-427486,-25.104%
testBurnLiquidity(),-477535,-25.129%
testSafeTransferFrom(),-295891,-25.295%
testGetSwapOutOnV2Pair(),-427507,-26.931%
testGetSwapInOnV2Pair(),-427507,-26.953%
testSweepLBToken(),-489987,-27.188%
testModifierCheckLength(),-535964,-28.163%
testSafeTransferFromReverts(),-537662,-28.222%
testForceDecay(),-2319546,-28.916%
testSafeBatchTransferFromReverts(),-606907,-29.824%
testAddLiquidityTaxToken(),-1076244,-29.978%
testTLowerThanTimestamp(),-2319913,-30.143%
testRemoveLiquidityReverseOrder(),-709108,-33.958%
testAddLiquidityNoSlippage(),-709107,-33.960%
testAddLiquidityAVAXReversed(),-1608674,-35.141%
testAddLiquidityAVAX(),-1758599,-35.555%
testSafeBatchTransferFrom(),-570685,-36.100%
testRemoveLiquiditySlippageReverts(),-2670446,-42.380%
testInternalBurn(uint256,uint256),-67156,-53.633%
testInternalMint(uint256),-67231,-55.603%
testInternalExcessiveBurnAmountReverts(uint128,uint128),-66987,-56.306%
Overall,-39447398,-1550.248%
Note that there are other instances of enumeration in the protocol. However, they only cost gas in admin functions or during pair creation. Also they enumerate addresses. Therefore I believe them to be justified, hence I only focused on enumeration of this core protocol functionality (adding and removing liquidity). I think it is essential to remove this enumeration to improve the efficiency of the protocol. Reducing gas cost during adding or removing liquidity is of utmost importance for the optimization of this protocol, as it will make it feasible to do bin operations at greater scale.
[G-02] Using Solidity version 0.8.17 will provide an overall gas optimization
Using at least 0.8.10
will save gas due to skipped extcodesize
check if there is a return value. Currently the contracts are compiled using version 0.8.7
(Foundry default). It is easily changeable to 0.8.17
using the command sed -i 's/0\.8\.7/^0.8.0/' test/*.sol && sed -i '4isolc = "0.8.17"' foundry.toml
. This will have the following total savings obtained by forge snapshot --diff | tail -1
:
Test Function,Gas Cost Difference,Percent Difference
Overall,-582995,-88.032%
[G-03] Ternary operation is cheaper than if-else statement
There are instances where a ternary operation can be used instead of if-else statement. In these cases, using ternary operation will save modest amounts of gas.
diff --git a/src/libraries/BitMath.sol b/src/libraries/BitMath.sol
index d088fdf..29c4034 100644
--- a/src/libraries/BitMath.sol
+++ b/src/libraries/BitMath.sol
@@ -16,9 +16,7 @@ library BitMath {
uint8 _bit,
bool _rightSide
) internal pure returns (uint256) {
- if (_rightSide) {
- return closestBitRight(_integer, _bit - 1);
- } else return closestBitLeft(_integer, _bit + 1);
+ return _rightSide ? closestBitRight(_integer, _bit - 1) : closestBitLeft(_integer, _bit + 1);
}
/// @notice Returns the most (or least) significant bit of `_integer`
@@ -26,9 +24,7 @@ library BitMath {
/// @param _isMostSignificant Whether we want the most (true) or the least (false) significant bit
/// @return The index of the most (or least) significant bit
function significantBit(uint256 _integer, bool _isMostSignificant) internal pure returns (uint8) {
- if (_isMostSignificant) {
- return mostSignificantBit(_integer);
- } else return leastSignificantBit(_integer);
+ return _isMostSignificant ? mostSignificantBit(_integer) : leastSignificantBit(_integer);
}
/// @notice Returns the index of the closest bit on the right of x that is non null
@@ -41,10 +37,8 @@ library BitMath {
uint256 _shift = 255 - bit;
x <<= _shift;
- if (x == 0) return type(uint256).max;
-
- // can't overflow as it's non-zero and we shifted it by `_shift`
- return mostSignificantBit(x) - _shift;
+ // can't underflow as it's non-zero and we shifted it by `_shift`
+ return (x == 0) ? type(uint256).max : mostSignificantBit(x) - _shift;
}
}
@@ -57,9 +51,7 @@ library BitMath {
unchecked {
x >>= bit;
- if (x == 0) return type(uint256).max;
-
- return leastSignificantBit(x) + bit;
+ return (x == 0) ? type(uint256).max : leastSignificantBit(x) + bit;
}
}
Note that this optimization seems to be dependent on usage of a more recent Solidity version. The following gas savings are on version 0.8.17
.
Test Function,Gas Cost Difference,Percent Difference
Overall,-13065,-0.200%
[G-04] Checking msg.sender
to not be zero address is redundant
There is an instance where msg.sender
is checked not to be zero address. This check is redundant as no private key is known for this address, hence there can be no transactions coming from the zero address. The following diff removes this redundant check.
diff --git a/src/libraries/PendingOwnable.sol b/src/libraries/PendingOwnable.sol
index f745362..97fb524 100644
--- a/src/libraries/PendingOwnable.sol
+++ b/src/libraries/PendingOwnable.sol
@@ -33,7 +33,7 @@ contract PendingOwnable is IPendingOwnable {
/// @notice Throws if called by any account other than the pending owner.
modifier onlyPendingOwner() {
- if (msg.sender != _pendingOwner || msg.sender == address(0)) revert PendingOwnable__NotPendingOwner();
+ if (msg.sender != _pendingOwner) revert PendingOwnable__NotPendingOwner();
_;
}
This will save tiny amounts of gas when PendingOwnable.becomeOwner()
is called.
[G-05] An element is cached to memory after it is used
Caching a struct element locally should be done before using it to save gas. The following diff applies this optimization.
diff --git a/src/LBPair.sol b/src/LBPair.sol
index 717270e..1d29c39 100644
--- a/src/LBPair.sol
+++ b/src/LBPair.sol
@@ -316,8 +316,8 @@ contract LBPair is LBToken, ReentrancyGuardUpgradeable, ILBPair {
if (_amountIn == 0) revert LBPair__InsufficientAmounts();
FeeHelper.FeeParameters memory _fp = _feeParameters;
- _fp.updateVariableFeeParameters(_pair.activeId);
uint256 _startId = _pair.activeId;
+ _fp.updateVariableFeeParameters(_startId);
uint256 _amountOut;
// Performs the actual swap, bin per bin
This will save small amount of gas when swapping.
Test Function,Gas Cost Difference,Percent Difference
testSwapExactTokensForTokensSinglePair(),-30,-0.009%
[G-06] Divisions by 2**n
can be replaced by right shift by n
There is an instance of division by 2
, which can be replaced by right shift by 1
. This simple bit operation is always cheaper than division. The following diff applies this optimization.
diff --git a/src/libraries/Oracle.sol b/src/libraries/Oracle.sol
index 974bc9f..fd9ca64 100644
--- a/src/libraries/Oracle.sol
+++ b/src/libraries/Oracle.sol
@@ -159,7 +159,7 @@ library Oracle {
uint256 _sampleTimestamp;
while (_high >= _low) {
unchecked {
- _middle = (_low + _high) / 2;
+ _middle = (_low + _high) >> 1;
assembly {
_id := addmod(_middle, _index, _activeSize)
}
Gas savings are obtained by forge snapshot --diff
.
Test Function,Gas Cost Difference,Percent Difference
testOracleSampleFromWith2Samples(),-4,-0.001%
testOracleSampleFromWith100SamplesNotAllInitialized(),-452,-0.002%
testFuzzingAddLiquidity(uint256),30,0.003%
testOracleSampleFromWith100Samples(),-1320,-0.005%
Overall,-1746,-0.005%
[G-07] Runtime cost can be optimized in detriment of the deploy cost
There are two optimization to improve runtime cost. Although the following optimizations will increase the gas cost of new pair creation and certain admin functions, it will decrease runtime cost of core protocol functions (swap, add/remove liquidity). Given that a pair is created once, but thousands of operations are made on it, optimizing for runtime can save a lot of gas in the long term.
[G-07A] Storing LBFactory._LBPairsInfo
info in both sorting order will save gas in runtime
When LBFactory.createLBPair()
is called, the pair information can be stored in both sorting orders of its reserve tokens. This will allow skipping _sortTokens()
, reducing the gas cost of _getLBPairInformation()
.
diff --git a/src/LBFactory.sol b/src/LBFactory.sol
index 32ee39c..7c66fbf 100644
--- a/src/LBFactory.sol
+++ b/src/LBFactory.sol
@@ -183,9 +183,7 @@ contract LBFactory is PendingOwnable, ILBFactory {
returns (LBPairInformation[] memory LBPairsAvailable)
{
unchecked {
- (IERC20 _tokenA, IERC20 _tokenB) = _sortTokens(_tokenX, _tokenY);
-
- bytes32 _avLBPairBinSteps = _availableLBPairBinSteps[_tokenA][_tokenB];
+ bytes32 _avLBPairBinSteps = _availableLBPairBinSteps[_tokenX][_tokenY];
uint256 _nbAvailable = _avLBPairBinSteps.decode(type(uint8).max, 248);
if (_nbAvailable > 0) {
@@ -194,7 +192,7 @@ contract LBFactory is PendingOwnable, ILBFactory {
uint256 _index;
for (uint256 i = MIN_BIN_STEP; i <= MAX_BIN_STEP; ++i) {
if (_avLBPairBinSteps.decode(1, i) == 1) {
- LBPairInformation memory _LBPairInformation = _LBPairsInfo[_tokenA][_tokenB][i];
+ LBPairInformation memory _LBPairInformation = _LBPairsInfo[_tokenX][_tokenY][i];
LBPairsAvailable[_index] = LBPairInformation({
binStep: i.safe24(),
@@ -273,6 +271,12 @@ contract LBFactory is PendingOwnable, ILBFactory {
createdByOwner: msg.sender == _owner,
ignoredForRouting: false
});
+ _LBPairsInfo[_tokenB][_tokenA][_binStep] = LBPairInformation({
+ binStep: _binStep,
+ LBPair: _LBPair,
+ createdByOwner: msg.sender == _owner,
+ ignoredForRouting: false
+ });
allLBPairs.push(_LBPair);
@@ -286,6 +290,7 @@ contract LBFactory is PendingOwnable, ILBFactory {
// Save the changes
_availableLBPairBinSteps[_tokenA][_tokenB] = _avLBPairBinSteps;
+ _availableLBPairBinSteps[_tokenB][_tokenA] = _avLBPairBinSteps;
}
emit LBPairCreated(_tokenX, _tokenY, _binStep, _LBPair, allLBPairs.length - 1);
@@ -315,14 +320,13 @@ contract LBFactory is PendingOwnable, ILBFactory {
uint256 _binStep,
bool _ignored
) external override onlyOwner {
- (IERC20 _tokenA, IERC20 _tokenB) = _sortTokens(_tokenX, _tokenY);
-
- LBPairInformation memory _LBPairInformation = _LBPairsInfo[_tokenA][_tokenB][_binStep];
+ LBPairInformation memory _LBPairInformation = _LBPairsInfo[_tokenX][_tokenY][_binStep];
if (address(_LBPairInformation.LBPair) == address(0)) revert LBFactory__AddressZero();
if (_LBPairInformation.ignoredForRouting == _ignored) revert LBFactory__LBPairIgnoredIsAlreadyInTheSameState();
- _LBPairsInfo[_tokenA][_tokenB][_binStep].ignoredForRouting = _ignored;
+ _LBPairsInfo[_tokenX][_tokenY][_binStep].ignoredForRouting = _ignored;
+ _LBPairsInfo[_tokenY][_tokenX][_binStep].ignoredForRouting = _ignored;
emit LBPairIgnoredStateChanged(_LBPairInformation.LBPair, _ignored);
}
@@ -595,7 +599,6 @@ contract LBFactory is PendingOwnable, ILBFactory {
IERC20 _tokenB,
uint256 _binStep
) private view returns (LBPairInformation memory) {
- (_tokenA, _tokenB) = _sortTokens(_tokenA, _tokenB);
return _LBPairsInfo[_tokenA][_tokenB][_binStep];
}
[G-07B] Using CREATE2 is cheaper than Clones
Using clone contracts requires extra proxy call, increasing the cost of all pair functions. Using CREATE2, although will increase cost of pair creation, will make all pair interactions cheaper.
[G-08] Making constant variables private will save gas during deployment
When constants are marked public, extra getter functions are created, increasing the deployment cost. Marking these functions private will decrease gas cost. One can still read these variables through the source code. If they need to be accessed by an external contract, a separate single getter function can be used to return all constants as a tuple. There are four instances of public constants.
src/LBFactory.sol:25: uint256 public constant override MAX_FEE = 0.1e18; // 10%
src/LBFactory.sol:27: uint256 public constant override MIN_BIN_STEP = 1; // 0.01%
src/LBFactory.sol:28: uint256 public constant override MAX_BIN_STEP = 100; // 1%, can't be greater than 247 for indexing reasons
src/LBFactory.sol:30: uint256 public constant override MAX_PROTOCOL_SHARE = 2_500; // 25%
[G-09] Using bool
s for storage incurs overhead
Credit: Description by IllIllI000.
// Booleans are more expensive than uint256 or any type that takes up a full
// word because each write operation emits an extra SLOAD to first read the
// slot's contents, replace the bits taken up by the boolean, and then write
// back. This is the compiler's defense against contract upgrades and
// pointer aliasing, and it cannot be disabled.
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/58f635312aa21f947cae5f8578638a85aa2519f5/contracts/security/ReentrancyGuard.sol#L23-L27
Use uint256(1)
and uint256(2)
for true/false to avoid a Gwarmaccess (100 gas) for the extra SLOAD, and to avoid Gsset (20000 gas) when changing from false
to true
, after having been true
in the past.
There are 2 instances of this issue:
src/LBFactory.sol-38- /// @notice Whether the createLBPair function is unlocked and can be called by anyone (true) or only by owner (false)
src/LBFactory.sol:39: bool public override creationUnlocked;
--
src/LBToken.sol-20- /// @dev Mapping from account to spender approvals
src/LBToken.sol:21: mapping(address => mapping(address => bool)) private _spenderApprovals;
[G‑10] Functions guaranteed to revert when called by normal users can be marked payable
Credit: Description by IllIllI000.
If a function modifier such as onlyOwner
is used, the function will revert if a normal user tries to pay the function. Marking the function as payable
will lower the gas cost for legitimate callers because the compiler will not include checks for whether a payment was provided. The extra opcodes avoided are
CALLVALUE
(2),DUP1
(3),ISZERO
(3),PUSH2
(3),JUMPI
(10),PUSH1
(3),DUP1
(3),REVERT
(0),JUMPDEST
(1),POP
(2), which costs an average of about 21 gas per call to the function, in addition to the extra deployment cost
There are 14 instances of this:
src/libraries/PendingOwnable.sol:59: function setPendingOwner(address pendingOwner_) public override onlyOwner {
src/libraries/PendingOwnable.sol:68: function revokePendingOwner() public override onlyOwner {
src/libraries/PendingOwnable.sol:84: function renounceOwnership() public override onlyOwner {
src/LBFactory.sol:215: function setLBPairImplementation(address _LBPairImplementation) external override onlyOwner {
src/LBFactory.sol:317: ) external override onlyOwner {
src/LBFactory.sol:350: ) external override onlyOwner {
src/LBFactory.sol:396: function removePreset(uint16 _binStep) external override onlyOwner {
src/LBFactory.sol:434: ) external override onlyOwner {
src/LBFactory.sol:468: function setFeeRecipient(address _feeRecipient) external override onlyOwner {
src/LBFactory.sol:474: function setFlashLoanFee(uint256 _flashLoanFee) external override onlyOwner {
src/LBFactory.sol:485: function setFactoryLockedState(bool _locked) external override onlyOwner {
src/LBFactory.sol:493: function addQuoteAsset(IERC20 _quoteAsset) external override onlyOwner {
src/LBFactory.sol:502: function removeQuoteAsset(IERC20 _quoteAsset) external override onlyOwner {
src/LBFactory.sol:520: function forceDecay(ILBPair _LBPair) external override onlyOwner {
0x0Louis (Trader Joe) confirmed
Alex the Entreprenerd (judge) commented:
[G-01] Owner token enumeration is an extremely expensive operation but it is not essential to the protocol
I think awarding 20k would be too high vs the rest of the reports, but this should save on average 40k as it skips 2 SSTORE on fresh slots[G-02] Using Solidity version 0.8.17 will provide an overall gas optimization
1kWill temporarily award 21k as it’s the one report that eliminated SLOADs vs less impactful refactorings
G-01 recommendation was applied to the main repo: traderjoe-xyz/joe-v2@b29b6bf
Alex the Entreprenerd (judge) commented:
Will not use 20k to rate the rest of reports but because of exceptional finding am awarding this report the win.
Disclosures
C4 is an open organization governed by participants in the community.
C4 Contests incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Contest submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.