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

First Depositor Share Manipulation Creates Perpetual Economic Imbalance

Summary

The share minting mechanism in PerpetualVault fails to maintain consistent ratios between deposits and shares when the vault has zero total shares. This allows early depositors to receive disproportionate share amounts, potentially leading to unfair value distribution among vault participants.

When a user makes the first deposit into an empty vault (totalShares = 0), the contract mints shares using a fixed ratio of 1e8 multiplied by the deposit amount. However, subsequent deposits use a different formula based on the total vault value and existing shares. This discontinuity in share calculation creates an economic disparity between the first and subsequent depositors.

Think of it like a new company issuing shares if the first investor gets shares at an arbitrary price while later investors pay market rates, it creates an unfair advantage.

  • First deposit: 1 token → 100M shares (1 * 1e8)

  • Second deposit: 1 token → shares based on totalAmount ratio

  • This creates a significant disparity in share/token ratio between deposits

Call fow Trace: deposit(1 token) → _mint() → totalShares = 0 → uses fixed 1e8 multiplier → mints 100M shares

Early depositors could receive an outsized portion of vault shares compared to later depositors. For example:

  1. Alice deposits 1 token → receives 100M shares

  2. Bob deposits 1000 tokens → receives fewer shares than Alice despite larger deposit

This undermines the vault's core principle of fair value distribution among participants.

Vulnerability Details

The PerpetualVault's share minting mechanism creates an economic disparity between the first depositor and all subsequent users. This vulnerability stems from using two fundamentally different calculation methods for share distribution. PerpetualVault.sol#_mint

function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
if (totalShares == 0) {
// 🚨 VULNERABILITY: First depositor gets privileged share ratio
// 💡 Uses arbitrary 1e8 multiplier instead of market-based pricing
_shares = depositInfo[depositId].amount * 1e8;
} else {
uint256 totalAmountBefore;
// 🔄 Position state check for different calculation paths
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
// 📊 Uses direct token balance for 1x long positions
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
// 💰 Uses total vault value for other positions
totalAmountBefore = _totalAmount(prices) - amount;
}
// ⚠️ Emergency fallback that could mask issues
if (totalAmountBefore == 0) totalAmountBefore = 1;
// ⚖️ Market-based share calculation for subsequent depositors
_shares = amount * totalShares / totalAmountBefore;
}
// 📝 State updates
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
// 💸 Fee refund handling
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[counter].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
}
}
// 📢 Event emission
emit Minted(depositId, depositInfo[depositId].owner, _shares, amount);
}

Imagine a new investment fund where the first investor gets shares at a fixed arbitrary price, while all later investors must pay based on the fund's actual net asset value. This creates an inherent unfairness in the system.

Technical Narrative

When Alice becomes the first depositor in a fresh vault, she triggers the following sequence

// PerpetualVault.sol
function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * 1e8; // Alice gets 100M shares per token
}
// ...
}

Later, when Bob deposits the same amount, his shares are calculated using

_shares = amount * totalShares / totalAmountBefore;

The stark difference between these formulas means Alice could receive 100 million shares for 1 USDC, while Bob might receive just 1 share for the same deposit, despite both users having equal economic contributions to the vault.

Consider this realistic scenario:

  1. Alice deposits 1 USDC → receives 100M shares

  2. Vault performs well, growing to 1000 USDC

  3. Bob deposits 1 USDC → receives ~100K shares

  4. When withdrawing, Alice controls 99.9% of the vault despite contributing only 0.1% of capital

This directly contradicts the vault's purpose of providing fair, proportional exposure to GMX perpetual positions.

Impact

When the vault is empty, the first depositor receives shares at a fixed rate of 1e8 per token. All subsequent depositors receive shares based on the vault's total value and existing shares, creating a permanent economic disparity.

Recommendations

Establish rational share pricing from the very first deposit, aligning initial share distribution with the protocol's long-term economic model.

// 🏦 PerpetualVault.sol
function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
if (totalShares == 0) {
// 🎯 Use consistent market-based pricing from start
_shares = amount * INITIAL_SHARE_RATE; // e.g. 1e18
require(_shares > 0 && _shares <= MAX_INITIAL_SHARES, "Invalid share mint");
} else {
uint256 totalAmountBefore;
// 📊 VaultReader.sol provides position valuations
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
// 💹 Uses GMX market prices via IGmxReader
totalAmountBefore = _totalAmount(prices) - amount;
}
require(totalAmountBefore > 0, "Invalid vault value");
_shares = amount * totalShares / totalAmountBefore;
}
// ✅ Validate share calculation
require(_shares > 0, "Zero shares");
// 📝 Update state
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
// 💸 GmxProxy.sol handles fee refunds
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[counter].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(
depositInfo[counter].owner,
depositInfo[counter].executionFee - usedFee
) {} catch {}
}
}
emit Minted(depositId, depositInfo[depositId].owner, _shares, amount);
}
Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

Support

FAQs

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

Give us feedback!