From 3df95f50c413a4342430cdb4b18c55c216286e92 Mon Sep 17 00:00:00 2001 From: David Lee Date: Thu, 11 Dec 2025 17:21:53 -0800 Subject: [PATCH 1/2] When adding new liquidity, check if tokenId was minted by controller --- src/ForeignController.sol | 9 +++++++++ src/MainnetController.sol | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/ForeignController.sol b/src/ForeignController.sol index 93ae0a43..916f7149 100644 --- a/src/ForeignController.sol +++ b/src/ForeignController.sol @@ -125,6 +125,9 @@ contract ForeignController is AccessControl { // ERC4626 exchange rate thresholds (1e36 precision) mapping(address token => uint256 maxExchangeRate) public maxExchangeRates; + // Stores UniswapV3 tokenIds that were created by this controller + mapping(uint256 tokenId => bool minted) public wasMintedByController; + /**********************************************************************************************/ /*** Initialization ***/ /**********************************************************************************************/ @@ -898,6 +901,8 @@ contract ForeignController is AccessControl { { _checkRole(RELAYER); + require(tokenId == 0 || wasMintedByController[tokenId], "ForeignController/invalid-token-id"); + UniswapV3Lib.UniswapV3PoolParams memory poolParams = uniswapV3PoolParams[pool]; uint256 maxSlippage = maxSlippages[pool]; @@ -920,6 +925,10 @@ contract ForeignController is AccessControl { twapSecondsAgo : poolParams.twapSecondsAgo }) ); + + if (tokenId == 0) { + wasMintedByController[tokenId_] = true; + } } function removeLiquidityUniswapV3( diff --git a/src/MainnetController.sol b/src/MainnetController.sol index be1d01ae..31ba8446 100644 --- a/src/MainnetController.sol +++ b/src/MainnetController.sol @@ -144,6 +144,9 @@ contract MainnetController is AccessControl { // ERC4626 exchange rate thresholds (1e36 precision) mapping(address token => uint256 maxExchangeRate) public maxExchangeRates; + // Stores UniswapV3 tokenIds that were created by this controller + mapping(uint256 tokenId => bool minted) public wasMintedByController; + /**********************************************************************************************/ /*** Initialization ***/ /**********************************************************************************************/ @@ -673,6 +676,8 @@ contract MainnetController is AccessControl { { _checkRole(RELAYER); + require(tokenId == 0 || wasMintedByController[tokenId], "MainnetController/invalid-token-id"); + UniswapV3Lib.UniswapV3PoolParams memory poolParams = uniswapV3PoolParams[pool]; uint256 maxSlippage = maxSlippages[pool]; @@ -695,6 +700,10 @@ contract MainnetController is AccessControl { twapSecondsAgo : poolParams.twapSecondsAgo }) ); + + if (tokenId == 0) { + wasMintedByController[tokenId_] = true; + } } function removeLiquidityUniswapV3( From 3cd2b1f06d13efe41003f50fd56c8e2c9e8cc400 Mon Sep 17 00:00:00 2001 From: David Lee Date: Thu, 11 Dec 2025 18:01:59 -0800 Subject: [PATCH 2/2] L-03: UniswapV3 liquidity can be deposited on donated or outdated positions --- test/grove-base-fork/UniswapV3.t.sol | 62 +++++++++++++++++++++---- test/grove-mainnet-fork/UniswapV3.t.sol | 51 ++++++++++++++++---- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/test/grove-base-fork/UniswapV3.t.sol b/test/grove-base-fork/UniswapV3.t.sol index 26b3b4cb..ba9bf206 100644 --- a/test/grove-base-fork/UniswapV3.t.sol +++ b/test/grove-base-fork/UniswapV3.t.sol @@ -337,8 +337,8 @@ contract ForeignControllerAddLiquidityFailureTests is UniswapV3TestBase { function _defaultMinPosition(UniswapV3Lib.TokenAmounts memory desired) internal pure returns (UniswapV3Lib.TokenAmounts memory) { return UniswapV3Lib.TokenAmounts({ - amount0: desired.amount0 * 99 / 100, - amount1: desired.amount1 * 99 / 100 + amount0: desired.amount0 * 98 / 100, + amount1: desired.amount1 * 98 / 100 }); } @@ -524,11 +524,23 @@ contract ForeignControllerAddLiquidityFailureTests is UniswapV3TestBase { } function test_addLiquidityUniswapV3_proxyDoesNotOwnTokenId() public { - uint256 tokenId = _mintExternalPosition(); - - vm.warp(block.timestamp + 1 hours); (UniswapV3Lib.Tick memory tick, UniswapV3Lib.TokenAmounts memory desired, UniswapV3Lib.TokenAmounts memory min) = _prepareDefaultAddLiquidity(); + + (uint256 tokenId,,,) = _addLiquidity( + 0, + tick, + desired, + min + ); + + vm.startPrank(address(almProxy)); + IERC721(UNISWAP_V3_POSITION_MANAGER).transferFrom(address(almProxy), stranger, tokenId); + vm.stopPrank(); + + assertEq(IERC721(UNISWAP_V3_POSITION_MANAGER).ownerOf(tokenId), stranger, "Token should be owned by stranger"); + + vm.warp(block.timestamp + 1 hours); vm.startPrank(ALM_RELAYER); vm.expectRevert("UniswapV3Lib/proxy-does-not-own-token-id"); @@ -609,11 +621,16 @@ contract ForeignControllerAddLiquidityFailureTests is UniswapV3TestBase { foreignController.setUniswapV3AddLiquidityUpperTickBound(usdsAusdPool, 100000); vm.stopPrank(); - // Mint a USDS-USDC position and transfer it to the relayer - uint256 usdsUsdcTokenId = _mintExternalPosition(); - - vm.prank(stranger); - IERC721(UNISWAP_V3_POSITION_MANAGER).transferFrom(stranger, address(almProxy), usdsUsdcTokenId); + // Mint a USDS-USDC position + (UniswapV3Lib.Tick memory tick, UniswapV3Lib.TokenAmounts memory desired, UniswapV3Lib.TokenAmounts memory min) + = _prepareDefaultAddLiquidity(); + + (uint256 usdsUsdcTokenId,,,) = _addLiquidity( + 0, + tick, + desired, + min + ); vm.warp(block.timestamp + 1 hours); @@ -704,6 +721,31 @@ contract ForeignControllerAddLiquidityFailureTests is UniswapV3TestBase { ); vm.stopPrank(); } + + function test_addLiquidityUniswapV3_invalidTokenId() public { + uint256 tokenId = _mintExternalPosition(); + + // Transfer tokenId to proxy + vm.startPrank(stranger); + IERC721(UNISWAP_V3_POSITION_MANAGER).transferFrom(stranger, address(foreignController), tokenId); + vm.stopPrank(); + + (UniswapV3Lib.Tick memory tick, UniswapV3Lib.TokenAmounts memory desired, UniswapV3Lib.TokenAmounts memory min) + = _prepareDefaultAddLiquidity(); + + + vm.startPrank(ALM_RELAYER); + vm.expectRevert("ForeignController/invalid-token-id"); + foreignController.addLiquidityUniswapV3( + _getPool(), + 123, + tick, + desired, + min, + block.timestamp + 1 hours + ); + vm.stopPrank(); + } } contract ForeignControllerAddLiquidityTwapProtectionTests is UniswapV3TestBase { diff --git a/test/grove-mainnet-fork/UniswapV3.t.sol b/test/grove-mainnet-fork/UniswapV3.t.sol index f7f4c17c..3fed5d89 100644 --- a/test/grove-mainnet-fork/UniswapV3.t.sol +++ b/test/grove-mainnet-fork/UniswapV3.t.sol @@ -854,11 +854,23 @@ contract MainnetControllerAddLiquidityFailureTests is UniswapV3TestBase { } function test_addLiquidityUniswapV3_proxyDoesNotOwnTokenId() public { - uint256 tokenId = _mintExternalPosition(); - - vm.warp(block.timestamp + 1 hours); (UniswapV3Lib.Tick memory tick, UniswapV3Lib.TokenAmounts memory desired, UniswapV3Lib.TokenAmounts memory min) = _prepareDefaultAddLiquidity(); + + (uint256 tokenId,,,) = _addLiquidity( + 0, + tick, + desired, + min + ); + + vm.startPrank(address(almProxy)); + IERC721(UNISWAP_V3_POSITION_MANAGER).transferFrom(address(almProxy), stranger, tokenId); + vm.stopPrank(); + + assertEq(IERC721(UNISWAP_V3_POSITION_MANAGER).ownerOf(tokenId), stranger, "Token should be owned by stranger"); + + vm.warp(block.timestamp + 1 hours); vm.startPrank(relayer); vm.expectRevert("UniswapV3Lib/proxy-does-not-own-token-id"); @@ -938,11 +950,16 @@ contract MainnetControllerAddLiquidityFailureTests is UniswapV3TestBase { mainnetController.setUniswapV3AddLiquidityUpperTickBound(UNISWAP_V3_DAI_USDC_POOL, 100000); vm.stopPrank(); - // Mint a USDC-USDT position and transfer it to the relayer - uint256 usdcUsdtTokenId = _mintExternalPosition(); - - vm.prank(stranger); - IERC721(UNISWAP_V3_POSITION_MANAGER).transferFrom(stranger, address(almProxy), usdcUsdtTokenId); + // Mint a USDC-USDT position + (UniswapV3Lib.Tick memory tick, UniswapV3Lib.TokenAmounts memory desired, UniswapV3Lib.TokenAmounts memory min) + = _prepareDefaultAddLiquidity(); + + (uint256 usdcUsdtTokenId,,,) = _addLiquidity( + 0, + tick, + desired, + min + ); vm.warp(block.timestamp + 1 hours); @@ -1099,6 +1116,24 @@ contract MainnetControllerAddLiquidityFailureTests is UniswapV3TestBase { ); vm.stopPrank(); } + + function test_addLiquidityUniswapV3_invalidTokenId() public { + (UniswapV3Lib.Tick memory tick, UniswapV3Lib.TokenAmounts memory desired, UniswapV3Lib.TokenAmounts memory min) + = _prepareDefaultAddLiquidity(); + + + vm.startPrank(relayer); + vm.expectRevert("MainnetController/invalid-token-id"); + mainnetController.addLiquidityUniswapV3( + _getPool(), + 123, + tick, + desired, + min, + block.timestamp + 1 hours + ); + vm.stopPrank(); + } } contract MainnetControllerAddLiquidityTwapProtectionTests is UniswapV3TestBase {