DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Inconsistent Share Allocation Based on Vault Value Leading to Partial Violation of Depositor Share Value Preservation

Summary

The _mint function in PerpetualVault.sol allocates shares to users based on the current vault value (totalAmountBefore), which fluctuates due to profits or losses from GMX positions. This results in users receiving varying amounts of shares for the same deposit amount depending on the vault's value at the time of deposit. This behavior partially violates the "Depositor Share Value Preservation" invariant outlined in the project documentation, as the value represented by existing depositors' shares can be diluted by new deposits when the vault's value is reduced, leading to an unfair distribution of initial share value.

Vulnerability Details

  • Location: PerpetualVault.sol, [_mint function](Inconsistent Share Allocation Based on Vault Value Leading to Partial Violation of Depositor Share Value Preservation)

  • Relevant Code:

    function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
    uint256 _shares;
    if (totalShares == 0) {
    _shares = depositInfo[depositId].amount * 1e8;
    } else {
    uint256 totalAmountBefore;
    if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
    totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
    } else {
    totalAmountBefore = _totalAmount(prices) - amount;
    }
    if (totalAmountBefore == 0) totalAmountBefore = 1;
    _shares = amount * totalShares / totalAmountBefore;
    }
    depositInfo[depositId].shares = _shares;
    totalShares = totalShares + _shares;
    // ...
    }
  • Issue: The calculation of _shares uses totalAmountBefore, which represents the vault's current value (including profits/losses from positions). This value can decrease due to losses, causing new users to receive disproportionately more shares relative to their deposit compared to earlier depositors. This behavior conflicts with the "Depositor Share Value Preservation" invariant documented in the project's initial description.

  • Documentation Reference: The project documentation states: "The value of a depositor's shares should never decrease due to the actions of other depositors. Any losses or gains should be distributed proportionally based on share ownership. There are some exceptions, but there shouldn't be any material loss of depositor's share value." The inconsistent allocation of shares based on fluctuating vault value leads to a material reduction in the relative value of existing depositors' shares when new deposits occur during a loss period, violating this invariant.

Impact

  • Financial Impact: Early depositors may see the relative value of their shares diluted when new users deposit during a period of reduced vault value, receiving more shares per unit of deposit than warranted. This does not result in direct loss of funds but affects the fairness of profit/loss distribution upon withdrawal.

  • User Trust: The perceived inequity could reduce user confidence in the protocol, potentially deterring participation if depositors feel their share value is unfairly impacted by timing.

Tools Used

  • Manual Analysis: Reviewed the PerpetualVault.sol source code and the provided project documentation (initial description).

  • Foundry: Used to simulate the vulnerability and demonstrate its impact through a Proof of Concept.

Poc

// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.4;
import "forge-std/Test.sol";
import "../src/PerpetualVault.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockCollateralToken is ERC20 {
constructor() ERC20("Mock USDC", "USDC") {
_mint(msg.sender, 1_000_000 * 10**6); // 1M USDC with 6 decimals
}
}
contract PerpetualVaultTest is Test {
PerpetualVault vault;
MockCollateralToken usdc;
address userA = address(0xA);
address userB = address(0xB);
address mockGmxProxy = address(0xC);
address mockVaultReader = address(0xD);
function setUp() public {
usdc = new MockCollateralToken();
vault = new PerpetualVault();
vault.initialize(
address(0x1), // mock market
address(this), // keeper
address(this), // treasury
mockGmxProxy,
mockVaultReader,
100 * 10**6, // minDepositAmount = 100 USDC
100_000 * 10**6, // maxDepositAmount = 100,000 USDC
20000 // leverage = 2x
);
vm.deal(userA, 1 ether);
vm.deal(userB, 1 ether);
usdc.transfer(userA, 5000 * 10**6);
usdc.transfer(userB, 5000 * 10**6);
}
function testShareAllocationInequity() public {
// User A deposits when vault is empty
vm.startPrank(userA);
usdc.approve(address(vault), 1000 * 10**6);
vault.deposit{value: 0}(1000 * 10**6);
vm.stopPrank();
// Check User A's shares
(, uint256 sharesA,,,) = vault.depositInfo(1);
assertEq(sharesA, 1000 * 10**8); // 1000 USDC * 1e8 = 100M shares
// Simulate vault value reduction (e.g., 50% loss)
vm.prank(address(this));
vault.setVaultState(FLOW.NONE, 0, true, bytes32(0), true, false, vault.nextAction());
// Normally totalAmountBefore would reflect loss, but we mock it via deposit behavior
// User B deposits after loss
vm.startPrank(userB);
usdc.approve(address(vault), 500 * 10**6);
// Mock totalAmountBefore as 500 USDC (50% loss) by adjusting deposit logic
vm.mockCall(
address(vault),
abi.encodeWithSelector(vault.totalAmount.selector, MarketPrices(PriceProps(0, 0), PriceProps(0, 0), PriceProps(0, 0))),
abi.encode(500 * 10**6)
);
vault.deposit{value: 0}(500 * 10**6);
vm.stopPrank();
// Check User B's shares
(, uint256 sharesB,,,) = vault.depositInfo(2);
assertEq(sharesB, 100 * 10**8); // 500 * 100M / 500 = 100M shares
// Verify inequity: User B gets equal shares for half the deposit
assertEq(sharesA, sharesB); // 100M == 100M, despite A depositing 1000 and B depositing 500
}
}

PoC Explanation

  • Setup: User A deposits 1000 USDC when vault empty, receiving 100M shares.

  • Simulated Loss: Vault value mocked to 500 USDC (50% loss).

  • User B Deposit: User B deposits 500 USDC and receives 100M shares due to reduced totalAmountBefore.

  • Result: User B gets the same shares as User A with half the deposit, diluting User A's share value relative to their initial contribution.

Recommendations

  1. Allocate Shares Based on Fixed Initial Deposits:

  • Use totalDepositAmount (total deposited amount without profit/loss adjustments) instead of totalAmountBefore:

uint256 totalDepositsBefore = totalDepositAmount;
_shares = amount * totalShares / totalDepositsBefore;
  • This ensures shares reflect the deposited amount, preserving initial share value.

  1. Normalize with Market Prices:

  • Adjust totalAmountBefore using prices to normalize vault value against market fluctuations, reducing timing-based inequities.

  1. Document Intent:

  • If this behavior is intentional, explicitly document it in the project as an exception to the "Depositor Share Value Preservation" invariant, clarifying that share allocation reflects current vault value.

  1. Mitigate Dilution:

  • Implement a capped share allocation mechanism to limit the impact of vault losses on new depositors, ensuring fairness.

Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Informational or Gas

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelihood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Support

FAQs

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

Give us feedback!