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

Share Accounting Desynchronization in Leveraged Trading

Summary

Shares in PerpetualVault fails to maintain consistency between totalShares and the sum of individual deposit shares. This breaks the fundamental accounting of the vault, potentially leading to incorrect share calculations during deposits and withdrawals. The core economic principle of share-based accounting is compromised.

The issue centers around the PerpetualVault's share accounting system. The vault tracks user deposits through shares, similar to how a traditional LP token works. It requires that totalShares always equals the sum of all individual deposit shares, which is crucial for maintaining fair value distribution among depositors.

But looking at the PerpetualVault contract, we see share minting occurs in the _mint () function during deposits

function afterOrderExecution(...) external nonReentrant {
// ENTRY POINT: GMX callback after position changes
// CRITICAL Share calculations happen here during deposit flow
if (orderResultData.orderType == Order.OrderType.MarketIncrease) {
if (flow == FLOW.DEPOSIT) {
// Asynchronous price impact calculation
int256 priceImpact = vaultReader.getPriceImpactInCollateral(...);
// VULNERABILITY: Share minting uses potentially stale position values
_mint(counter, increased, false, prices);
}
}
}
function _mint(...) internal {
// Share calculation logic
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * 1e8;
} else {
// CRITICAL Relies on _totalAmount() which includes position values
totalAmountBefore = _totalAmount(prices) - amount;
_shares = amount * totalShares / totalAmountBefore;
}
// State update without position value reconciliation
totalShares = totalShares + _shares;
}
function _totalAmount(...) internal view {
// Position value lookup
IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(...);
// VULNERABILITY: Position values can change between share calculations
uint256 total = positionData.netValue / prices.shortTokenPrice.min;
}

The issue flows through these functions:

  1. GMX position updates trigger afterOrderExecution() function

  2. Which calls _mint during deposits

  3. Which relies on _totalAmount function for share calculation

  4. But position values from VaultReader can change asynchronously

This creates the share accounting desynchronization vulnerability.

The path where the invariant can break is in the share calculation logic. See how the share calculation depends on _totalAmount() which involves complex GMX position calculations through VaultReader. This creates multiple states where share accounting can become inconsistent.

  1. During position updates through GMX callbacks

  2. When handling liquidations

  3. During the compound flow where new shares are minted

The root cause is the lack of atomic updates between position value changes and share recalculations. When GMX positions change in value, the total vault value changes, but share accounting may not immediately reflect this.

This inconsistency means users could receive incorrect amounts during withdrawals since the withdrawal amount calculation relies on the share ratio: PerpetualVault.sol#L1137

amount = withdrawn + balanceBeforeWithdrawal * shares / totalShares;

Vulnerability Details

The error is in how the PerpetualVault handles share accounting during complex trading operations. The contract maintains a critical invariant, totalShares must equal the sum of all deposit shares, but this can break during asynchronous GMX operations.

When users deposit into the vault, they receive shares representing their ownership. The share calculation happens in _mint()

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;

This means that share minting depends on _totalAmount(), which involves complex GMX position valuations through VaultReader. The asynchronous nature of GMX operations creates a timing gap between position value changes and share updates.

Like a ledger that doesn't immediately reflect all pending transactions, when GMX executes trades through callbacks, the vault's total value changes, but share accounting may lag behind. This desynchronization breaks our core invariant.

During withdrawals, users could receive incorrect amounts because the withdrawal calculation uses potentially stale share ratios

amount = withdrawn + balanceBeforeWithdrawal * shares / totalShares;

Impact

In the PerpetualVault, users deposit USDC to gain exposure to leveraged trading positions through GMX. The vault tracks these deposits using shares, similar to how a mutual fund works. Each vault maintains a specific leverage ratio (1x-3x) and can take long or short positions through GMX perpetuals.

The share calculation in _mint() relies on _totalAmount(), which includes the value of GMX positions

function _totalAmount(MarketPrices memory prices) internal view returns (uint256) {
// Position values affect total vault value
IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(curPositionKey, prices);
return total;
}

When GMX executes trades through its keeper system, position values change asynchronously through callbacks. This creates a timing mismatch, the vault's share accounting doesn't immediately reflect these position value changes.

Tools Used

Manual

Recommendations

Implement atomic share reconciliation after GMX position updates, ensuring the vault maintains accurate ownership accounting throughout all trading operations.

// In PerpetualVault.sol
function afterOrderExecution(...) external nonReentrant {
// First: Handle GMX position update
if (orderResultData.orderType == Order.OrderType.MarketIncrease) {
curPositionKey = positionKey;
// Second: Get latest position data through VaultReader
IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(
curPositionKey,
prices
);
// NEW: Reconcile shares based on updated position value
_reconcileShares(positionData, prices);
// Third: Process deposit if needed
if (flow == FLOW.DEPOSIT) {
_mint(counter, increased, false, prices);
}
}
}
// NEW: Add share reconciliation function
function _reconcileShares(
IVaultReader.PositionData memory positionData,
MarketPrices memory prices
) internal {
// Calculate true total value including latest position data
uint256 currentTotalValue = IERC20(indexToken).balanceOf(address(this)) *
prices.indexTokenPrice.min / prices.shortTokenPrice.min +
collateralToken.balanceOf(address(this)) +
positionData.netValue / prices.shortTokenPrice.min;
// Update share values to match current total value
if (totalShares > 0) {
// Adjust shares based on new total value
_updateShareValues(currentTotalValue);
}
}

So that we ensure

  1. Position values are captured immediately after GMX updates

  2. Share values are reconciled before any new mints/burns

  3. Total shares maintain consistency with actual vault value

This creates atomic updates between GMX position changes and vault share accounting

Updates

Lead Judging Commences

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

invalid_shares_slippage

Shares represent a part of the vault. Even if someone performs a frontrun or sandwich attack, you will still have the corresponding amount of shares representing your deposit. A user could add liquidity two days later, and you would still have the same amount of shares.

Support

FAQs

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