sherlock-audit / 2024-06-new-scope-judging

0 stars 0 forks source link

ether_sky - The rewards distribution in the NFTPositionManager is unfair #393

Open sherlock-admin4 opened 4 weeks ago

sherlock-admin4 commented 4 weeks ago

ether_sky

Medium

The rewards distribution in the NFTPositionManager is unfair

Summary

In NFTPositionManager, users can deposit or borrow assets and earn rewards in zero. However, the distribution of these rewards is not working correctly.

Vulnerability Detail

Let's consider a pool, P, and an asset, A, with a current liquidity index of 1.5.

57:  uint256 balance = pool.getBalance(params.asset, address(this), params.tokenId);   _handleSupplies(address(pool), params.asset, params.tokenId, balance); }

- The `shares` for these users would be calculated as `100 ÷ 1.5 = 66.67 shares` each in the `P`.

Now, in the `_handleSupplies` function, we compute the `total supply` and `balances` for these users for the `assetHash` `H`.
```solidity
function _handleSupplies(address pool, address asset, uint256 tokenId, uint256 balance) internal {
  bytes32 _assetHash = assetHash(pool, asset, false);  // H
  uint256 _currentBalance = _balances[tokenId][_assetHash];  // 0

  _updateReward(tokenId, _assetHash);
  _balances[tokenId][_assetHash] = balance; // 100
  _totalSupply[_assetHash] = _totalSupply[_assetHash] - _currentBalance + balance;
}

Those values would be as below:

Now, the total supply and user balances for assetHash H become:

At this point, User U1’s asset balance in the pool P is the largest, being 1 wei more than U2's and 23.3 more than U3's. Yet, U1 receives the smallest rewards because their balance was never updated in the NFTPositionManager. In contrast, User U2 receives more rewards due to the balance update caused by withdrawing just 1 wei.

The issue:

This system is unfair because:

The solution:

Instead of using the asset balance as the rewards basis, we should use the shares in the pool. Here’s how the updated values would look:

This way, the rewards distribution becomes fair, as it is based on actual contributions to the pool.

Impact

Code Snippet

https://github.com/sherlock-audit/2024-06-new-scope/blob/c8300e73f4d751796daad3dadbae4d11072b3d79/zerolend-one/contracts/core/positions/NFTPositionManagerSetters.sol#L57-L58

Tool used

Manual Review

Recommendation

function _supply(AssetOperationParams memory params) internal nonReentrant {
  pool.supply(params.asset, address(this), params.amount, params.tokenId, params.data);

-  uint256 balance = pool.getBalance(params.asset, address(this), params.tokenId);
+  uint256 balance = pool.getBalanceRaw(params.asset, address(this), params.tokenId).supplyShares;

  _handleSupplies(address(pool), params.asset, params.tokenId, balance);
}

The same applies to the _borrow, _withdraw, and _repay functions.

nevillehuang commented 2 weeks ago

request poc

Is there a permisionless update functionality?

sherlock-admin4 commented 2 weeks ago

PoC requested from @etherSky111

Requests remaining: 25

etherSky111 commented 2 weeks ago

Thanks for judging.

There is a clear issue. https://github.com/sherlock-audit/2024-06-new-scope-judging/issues/473 To test this issue properly, we need to resolve the above issue first.

function getSupplyBalance(DataTypes.PositionBalance storage self, uint256 index) public view returns (uint256 supply) {
-  uint256 increase = self.supplyShares.rayMul(index) - self.supplyShares.rayMul(self.lastSupplyLiquidtyIndex);
-  return self.supplyShares + increase;
- 
+  return self.supplyShares.rayMul(index);
}

Below is test code.

function supplyForUser(address user, uint256 supplyAmount, uint256 tokenId, bool mintNewToken) public {
  uint256 mintAmount = supplyAmount;
  DataTypes.ExtraData memory data = DataTypes.ExtraData(bytes(''), bytes(''));
  INFTPositionManager.AssetOperationParams memory params =
    INFTPositionManager.AssetOperationParams(address(tokenA), user, supplyAmount, tokenId, data);

  _mintAndApprove(user, tokenA, mintAmount, address(nftPositionManager));

  vm.startPrank(user);
  if (mintNewToken == true) {
    nftPositionManager.mint(address(pool));
  }
  nftPositionManager.supply(params);
  vm.stopPrank();
}

function borrowForUser(address user, uint256 borrowAmount, uint256 tokenId) public {
  DataTypes.ExtraData memory data = DataTypes.ExtraData(bytes(''), bytes(''));
  INFTPositionManager.AssetOperationParams memory params =
    INFTPositionManager.AssetOperationParams(address(tokenA), user, borrowAmount, tokenId, data);

  vm.startPrank(user);
  nftPositionManager.borrow(params);
  vm.stopPrank();
}

function testRewardDistribution() external {
  DataTypes.ReserveData memory reserveData_0 = pool.getReserveData(address(tokenA));
  console2.log('initial liquidity index                => ', reserveData_0.liquidityIndex);

  address U1 = address(11);
  address U2 = address(12);
  address U3 = address(13);

  /**
    User U1 wants to mint a new NFT (tokenId = 1) and supply 100 ether token
    */
  supplyForUser(U1, 100 ether, 1, true);
  /**
    User U2 wants to mint a new NFT (tokenId = 2) and supply 100 ether token
    */
  supplyForUser(U2, 100 ether, 2, true);

  bytes32 assetHash = nftPositionManager.assetHash(address(pool), address(tokenA), false);

  uint256 balancesOfU1 = nftPositionManager.balanceOfByAssetHash(1, assetHash);
  uint256 balancesOfU2 = nftPositionManager.balanceOfByAssetHash(2, assetHash);
  console2.log('initial balance of U1 for rewards      => ', balancesOfU1);
  console2.log('initial balance of U2 for rewards      => ', balancesOfU2);

  /**
    For testing purposes, Alice mints a new NFT (tokenId = 3), supplies 1000 ether, and borrows 600 Ether. 
    This action increases the pool's liquidity rate to a non-zero value.
    */
  supplyForUser(alice, 1000 ether, 3, true);
  borrowForUser(alice, 600 ether, 3);

  DataTypes.ReserveData memory reserveData_1 = pool.getReserveData(address(tokenA));
  console2.log('current liquidity rate                 => ', reserveData_1.liquidityRate);

  /**
    Skipping 2000 days is done for testing purposes to increase the liquidity index. 
    In a real environment, the liquidity index would increase continuously over time.
    */
  vm.warp(block.timestamp + 2000 days);

  pool.forceUpdateReserve(address(tokenA));
  DataTypes.ReserveData memory reserveData_2 = pool.getReserveData(address(tokenA));
  console2.log('updated liquidity index                => ', reserveData_2.liquidityIndex);

  /**
    User U2 supplies 100 wei (a dust amount) to trigger an update of the balances for rewards.
    */
  supplyForUser(U2, 100, 2, false);

  uint256 balancesOfU1Final = nftPositionManager.balanceOfByAssetHash(1, assetHash);
  uint256 balancesOfU2Final = nftPositionManager.balanceOfByAssetHash(2, assetHash);
  console2.log('final balance of U1 for rewards        => ', balancesOfU1Final);
  console2.log('final balance of U2 for rewards        => ', balancesOfU2Final);

  /**
    User U3 wants to mint a new NFT (tokenId = 4) and supply 110 ether token
    */
  supplyForUser(U3, 110 ether, 4, true);
  uint256 balancesOfU3Final = nftPositionManager.balanceOfByAssetHash(4, assetHash);
  console2.log('final balance of U3 for rewards        => ', balancesOfU3Final);
}

Everything is in below log:

initial liquidity index                =>  1000000000000000000000000000
initial balance of U1 for rewards      =>  100000000000000000000
initial balance of U2 for rewards      =>  100000000000000000000
current liquidity rate                 =>  43490566037735849056603774
updated liquidity index                =>  1238304471439648487981390542
final balance of U1 for rewards        =>  100000000000000000000
final balance of U2 for rewards        =>  123830447143964848898
final balance of U3 for rewards        =>  110000000000000000000

There is no reward system that requires users to continuously update their balances. How can users realistically update their balances every second to receive accurate rewards? Is this practical?

0xspearmint1 commented 4 days ago

escalate

This issue does not meet Sherlock's criteria for a medium issue that requires the following:

Causes a loss of funds but requires certain external conditions or specific states, or a loss is highly constrained. The loss of the affected party must exceed 0.01% and 10 USD.

For this issue to cause a 0.01% loss there must be an unrealistic increase in the liquidityIndex in an extremely small 14 day period. The POC provided inflates the liquidity index by borrowing a 60% of the funds at a huge interest rate for 5.5 years, this is absolutely not realistic and will never happen.

sherlock-admin3 commented 4 days ago

escalate

This issue does not meet Sherlock's criteria for a medium issue that requires the following:

Causes a loss of funds but requires certain external conditions or specific states, or a loss is highly constrained. The loss of the affected party must exceed 0.01% and 10 USD.

For this issue to cause a 0.01% loss there must be an unrealistic increase in the liquidityIndex in an extremely small 14 day period. The POC provided inflates the liquidity index by borrowing a 60% of the funds at a huge interest rate for 5.5 years, this is absolutely not realistic and will never happen.

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.

aliX40 commented 3 days ago

This issue is a high severity bug:

obou07 commented 3 days ago

escalate per comment

sherlock-admin3 commented 3 days ago

escalate per comment

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.