Platform: Code4rena
Start Date: 14/10/2022
Pot Size: $100,000 USDC
Total HM: 12
Participants: 75
Period: 9 days
Judge: GalloDaSballo
Total Solo HM: 1
Id: 171
League: ETH
Rank: 4/75
Findings: 3
Award: $12,032.92
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: Jeiwan
Also found by: KIntern_NA, cccz
4170.8571 USDC - $4,170.86
https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBRouter.sol#L891 https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBRouter.sol#L896
Function LBRouter._swapSupportingFeeOnTransferTokens
is a helper function to swap exact tokens supporting for a fee on transfer tokens. This function will check the pair of _token
and _tokenNext
is JoePair
or LBPair
using _binStep
.
_binStep == 0
, it will be a JoePair
otherwise it will be an LBPair
.// url = https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBRouter.sol#L887-L903 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); }
As we can see when _binStep == 0
and _token < _tokenNext
(in another word we swap through JoePair
and pair'stoken0
is _token
and token1
is _tokenNext
), it will
reserve0
, reserve1
)_token
's balance_amountOut
by using the formula_amountOut = (_reserve1 * (_balance - _reserve0) * 997) / (_balance * 1_000)
IJoePair(_pair).swap(0, _amountOut, _recipient, "")
But the formula to calculate the amountOut
seem weird. Is it true?
Let's do the math here to confirm whether it's true or not.
Input:
_reserve0 (r0)
: reserve of token0 in pair_reserve1 (r1)
: reserve of token1 in pair_balance (b)
: balance of token0 after transfering _token
to pair.Output:
amountOut'
: the amount of token1 we can get.Generate Formula
Cause JoePair
takes 0.3% of amountIn
as fee, we get
amountIn = b - r0
amountDeductFee = 0.997 * amountIn
balanceDeductFee = r0 + amountDeductFee
Following the constant product formula, we have
r0 * r1 = balanceDeductFee * (r1-amountOut) <=> amountOut' = r1 - r0 * r1 / balanceDeductFee <=> amountOut' = (r1 * balanceDeductFee - r0 * r1) / balanceDeductFee <=> amountOut' = (r1 * (r0 + amountDeductFee) - r0 * r1) / balanceDeductFee <=> amountOut' = (r1 * amountDeductFee) / balanceDeductFee <=> amountOut' = r1 * 0.997 * (b - r0) / (r0 + 0.997 * amountIn) <=> amountOut' = (r1 * (b - r0) * 997) / (r0 * 1000 + 997 * amountIn) <=> amountOut' = (r1 * (b - r0) * 997) / (b * 997 + 3 * r0) <=> amountOut' = (_reserve1 * (_balance - _reserve0) * 997) / (_balance * 997 + 3 * _reserve0)
As we can see amountOut'
is different from amountOut
, the denominator of amountOut
is _balance * 1000
when the denominator of amountOut'
is _balance * 997 + 3 * _reserve0
As we can see above, amountOut < amountOut'
(cause _balance * 1000 > _balance * 997 + 3 * _reserve0
), so user will get a smaller amount out than expected.
We can check function _swapSupportingFeeOnTransferTokens
in JoeRouter02.sol
following this link
# JoeRouter02.sol // https://github.com/traderjoe-xyz/joe-core/blob/e9a5757d87d60858eee2858afd2fb5a63498fc56/contracts/traderjoe/JoeRouter02.sol#L374-L375 function _swapSupportingFeeOnTransferTokens(address[] memory path, address _to) internal virtual { ... amountInput = IERC20Joe(input).balanceOf(address(pair)).sub(reserveInput); amountOutput = JoeLibrary.getAmountOut(amountInput, reserveInput, reserveOutput); ... } # JoeLibrary.sol // https://github.com/traderjoe-xyz/joe-core/blob/e9a5757d87d60858eee2858afd2fb5a63498fc56/contracts/traderjoe/libraries/JoeLibrary.sol#L63-L74 function getAmountOut( uint256 amountIn, uint256 reserveIn, uint256 reserveOut ) internal pure returns (uint256 amountOut) { require(amountIn > 0, "JoeLibrary: INSUFFICIENT_INPUT_AMOUNT"); require(reserveIn > 0 && reserveOut > 0, "JoeLibrary: INSUFFICIENT_LIQUIDITY"); uint256 amountInWithFee = amountIn.mul(997); uint256 numerator = amountInWithFee.mul(reserveOut); uint256 denominator = reserveIn.mul(1000).add(amountInWithFee); amountOut = numerator / denominator; }
As we can see from code above, the formula to calculate amountOut is the same as the calculation I described in Vulnerable detail section.
Manual review
Modify function LBRouter._swapSupportingFeeOnTransferTokens
as follow
if (_binStep == 0) { (uint256 _reserve0, uint256 _reserve1, ) = IJoePair(_pair).getReserves(); if (_token < _tokenNext) { uint256 _balance = _token.balanceOf(_pair); // fix here uint256 _amountOut = (_reserve1 * (_balance - _reserve0) * 997) / (_balance * 997 + 3 * _reserve0); IJoePair(_pair).swap(0, _amountOut, _recipient, ""); } else { uint256 _balance = _token.balanceOf(_pair); // fix here uint256 _amountOut = (_reserve0 * (_balance - _reserve1) * 997) / (_balance * 997 + 3 * _reserve1); IJoePair(_pair).swap(_amountOut, 0, _recipient, ""); } } else { ILBPair(_pair).swap(_tokenNext == ILBPair(_pair).tokenY(), _recipient); }
#0 - GalloDaSballo
2022-10-26T17:07:41Z
High quality as welL
#1 - GalloDaSballo
2022-10-26T17:07:48Z
#2 - c4-judge
2022-11-23T18:32:18Z
GalloDaSballo marked the issue as not a duplicate
#3 - c4-judge
2022-11-23T18:32:26Z
GalloDaSballo marked the issue as duplicate of #345
#4 - Simon-Busch
2022-12-05T06:47:37Z
Marked this issue as satisfactory as requested by @GalloDaSballo
🌟 Selected for report: KIntern_NA
5422.1143 USDC - $5,422.11
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).
/// url = https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBPair.sol#L905-L917 /// @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
.
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:
binStep = DEFAULT_BIN_STEP = 25
volatilityAccumulated
from 0
to 60000
factory.setFeeParametersOnPair
to set new fee parameters.volatilityAccumulated
changed to value 0
(It should still be unchanged after factory.setFeeParametersOnPair
)binStep
and it changed from25
to 60025
binStep
has that value because line 915 set binStep = uint16(volatilityAccumulated) | binStep = 60000 | 25 = 60025
.binStep
value will break all the functionality of LBPair
cause binStep > Constant.BASIS_POINT_MAX = 10000
--> Error: BinStepOverflows
Foundry
Modify function LBPair._setFeesParaters
as follow:
/// url = https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBPair.sol#L905-L917 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))) } }
#0 - trust1995
2022-10-23T20:55:51Z
Dup of #425
#1 - GalloDaSballo
2022-10-26T16:41:33Z
Really high quality
#2 - GalloDaSballo
2022-10-26T16:42:02Z
Picking this one as primary because of the illustration, absolutely personal preference
#3 - GalloDaSballo
2022-11-10T15:23:26Z
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
#4 - c4-judge
2022-11-10T15:23:58Z
GalloDaSballo marked the issue as selected for report
#5 - c4-judge
2022-11-23T18:31:32Z
GalloDaSballo marked the issue as primary issue
#6 - Simon-Busch
2022-12-05T06:28:12Z
Marked this issue as Satisfactory as requested by @GalloDaSballo
#7 - Simon-Busch
2022-12-05T06:46:21Z
Revert, wrong action.
🌟 Selected for report: KIntern_NA
Also found by: Jeiwan, cccz, hansfriese
2439.9514 USDC - $2,439.95
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
.
_binStep == 0
, it will be a JoePair
otherwise it will be an LBPair
.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
reserveIn
, reserveOut
)_amountIn
by using the formulaamountsIn[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 gainOutput:
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)
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
.
amountsIn
from function _getAmountsIn
. So amountsIn
will be [incorrectAmountIn
, userDesireAmount
].
// url = https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBRouter.sol#L440 amountsIn = _getAmountsIn(_pairBinSteps, _pairs, _tokenPath, _amountOut);
incorrectAmountIn
to _pairs[0]
to prepare for the swap.
// url = https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBRouter.sol#L444 _tokenPath[0].safeTransferFrom(msg.sender, _pairs[0], amountsIn[0]);
_swapTokensForExactToken
to execute the swap.
In this step it will reach to line 841 which will set the expected// url = https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBRouter.sol#L446 uint256 _amountOutReal = _swapTokensForExactTokens(_pairs, _pairBinSteps, _tokenPath, amountsIn, _to);
amountOut = amountsIn[i+1] = amountsIn[1] = userDesireAmount
.
So after calling// url = https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBRouter.sol#L841 amountOut = _amountsIn[i + 1];
IJoePair(_pair).swap()
, the user just gets exactly amountOut
and wastes a lot of tokenIn that (s)he transfers to the pool.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 u can read the comments from test script above)
WAVAX/USDC
was around 15.57. We try to use LBRouter function swapTokensForExactTokens
to swap 10$ WAVAX (10e18 wei) to 1$ USDC (1e6 wei). But it reverts with the error LBRouter__MaxAmountInExceeded
.
But when we swap directly to JoePair, it swap successfully 10$ AVAX (10e18 wei) to 155$ USDC (155e6 wei).swapTokensForExactTokens
again with very large amountInMax
to swap 1$ USDC (1e6 wei). It swaps successfully but needs to pay a very large amount WAVAX (much more than price).Foundry
Modify function LBRouter._getAmountsIn
as follow
// url = https://github.com/code-423n4/2022-10-traderjoe/blob/79f25d48b907f9d0379dd803fc2abc9c5f57db93/src/LBRouter.sol#L717-L728 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); }
#0 - trust1995
2022-10-23T20:48:37Z
Dup of #442
#1 - GalloDaSballo
2022-10-26T16:40:01Z
Making Primary as the most thorough
#2 - GalloDaSballo
2022-11-09T16:15:34Z
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
#3 - c4-judge
2022-11-09T16:15:54Z
GalloDaSballo marked the issue as selected for report
#4 - c4-judge
2022-11-14T13:58:11Z
GalloDaSballo marked the issue as primary issue
#5 - Simon-Busch
2022-12-05T06:28:48Z
Marked this issue as Satisfactory as requested by @GalloDaSballo
#6 - Simon-Busch
2022-12-05T06:48:04Z
Revert, wrong action.