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

Position Sizes Can Show Value Without Backing Tokens - The "Ghost Value" Bug

Summary

VaultReader contract allows positions to have non-zero USD size while having zero token size, breaking a fundamental size consistency. This can lead to incorrect position valuations and potentially affect the entire vault's accounting. The issue occurs in the VaultReader's position size tracking where GMX storage can end up with inconsistent state between USD and token denominated position sizes. When a position is created or modified, the contract assumes that if sizeInUsd is positive, sizeInTokens must also be positive but this invariant can be violated due to missing validation in the position management logic. We expects that any position with sizeInUsd > 0 must have sizeInTokens > 0. However, there's a scenario where shows

getPositionSizeInUsd(key) = 1000 (non-zero)
getPositionSizeInTokens(key) = 0

The question is - How can the position size in tokens be zero while having positive USD value?

The issue stems from the position size tracking in GMX's storage where:

  • SIZE_IN_USD and SIZE_IN_TOKENS are tracked separately

  • No invariant enforcement between these values at the storage level

  • Position updates can modify one value without properly updating the other

Vulnerability Details

Imagine an account showing $1000 in value but 0 actual dollars in the vault, this is essentially what's happening in our VaultReader contract. Positions can end up with non-zero USD size but zero token size, which should be mathematically impossible.

When users interact with the PerpetualVault, they're expecting their position sizes to be consistently tracked both in USD and tokens. The VaultReader contract serves as the source of truth for these values, pulling data from GMX storage. However, there's a critical gap in the validation chain. getPositionSizeInUsd(), getPositionInfo()

// πŸ” Core function that reads raw position size without validation
function getPositionSizeInUsd(bytes32 key) public view returns (uint256 sizeInUsd) {
// ⚠️ Direct storage read without cross-validation
sizeInUsd = dataStore.getUint(keccak256(abi.encode(key, SIZE_IN_USD)));
}
​
function getPositionInfo(bytes32 key, MarketPrices memory prices) external view returns (PositionData memory) {
// πŸ› Bug: Calls getPositionSizeInUsd but assigns to sizeInTokens variable!
uint256 sizeInTokens = getPositionSizeInUsd(key);
// 🚨 This check uses the wrong variable
if (sizeInTokens == 0) {
// 🏦 Returns empty position data
return PositionData({...});
}
​
// πŸ“Š Gets detailed position info from GMX
PositionInfo memory positionInfo = gmxReader.getPositionInfo(...);
​
// πŸ’° Complex net value calculation
uint256 netValue =
positionInfo.position.numbers.collateralAmount * prices.shortTokenPrice.min +
// ... other calculations
​
// πŸ“ˆ Returns potentially inconsistent position data
return PositionData({
sizeInUsd: positionInfo.position.numbers.sizeInUsd, // πŸ”’ Could be non-zero
sizeInTokens: positionInfo.position.numbers.sizeInTokens, // πŸ”„ Could be zero
// ...
});
}

The contract expects that any position with value (sizeInUsd > 0) must have actual tokens backing it (sizeInTokens > 0). But because SIZE_IN_USD and SIZE_IN_TOKENS are tracked independently in GMX's storage, they can become desynchronized.

When PerpetualVault calculates position values or executes trades, it relies on VaultReader for accurate position data. With inconsistent sizes:

  • Position PnL calculations could return incorrect values

  • Leverage calculations become unreliable

  • Risk management checks could be bypassed

For example, if a vault has $10,000 in sizeInUsd but 0 sizeInTokens, the getPositionInfo() function would attempt calculations using these inconsistent values, potentially affecting all users in that vault.

Impact

The PerpetualVault relies on VaultReader for critical calculations like PnL and leverage. With inconsistent sizes, these calculations become unreliable or can revert.

  1. The whitepaper's position management section assumes atomic updates

  2. GMX's storage layout allows independent size updates

  3. The VaultReader was trusted to always return consistent values

The VaultReader contract, which serves as the system's eyes into GMX positions, can report positions having USD value without any actual tokens backing them creating phantom value in the system.

When a position is opened through the PerpetualVault, it should always maintain consistency between its USD size and token size. Think of it like a forex trading account, if you have $1000 worth of EUR/USD position, you must have some actual euros in your position. But our VaultReader allows a state where sizeInUsd shows 1000 while sizeInTokens is 0, mathematically impossible in a healthy system.

The consequences are precise and severe. When calculating position values in getPositionInfo(), the contract uses these sizes to determine PnL and collateral requirements. With phantom positions, a vault showing $100,000 in positions might have zero actual backing tokens, leading to incorrect share price calculations and potentially allowing users to withdraw more value than the vault actually holds.

Recommendations

Enforce size consistency at the validation level

// πŸ›‘οΈ Core validation function to enforce size consistency
function validatePositionSizes(bytes32 key) internal view {
// πŸ“Š Get both size representations
uint256 sizeInUsd = getPositionSizeInUsd(key);
uint256 sizeInTokens = getPositionSizeInTokens(key);
// βš–οΈ Enforce fundamental size invariant
require(
(sizeInUsd == 0 && sizeInTokens == 0) || // πŸ”„ Both zero (closed position)
(sizeInUsd > 0 && sizeInTokens > 0), // πŸ“ˆ Both positive (valid position)
"Position size mismatch"
);
}
​
// πŸ”„ Updated position info function with validation
function getPositionInfo(bytes32 key, MarketPrices memory prices) external view returns (PositionData memory) {
// βœ… Add validation before processing
validatePositionSizes(key);
// πŸ“ Get position details from GMX
PositionInfo memory positionInfo = gmxReader.getPositionInfo(...);
// πŸ’« Calculate net value with validated sizes
uint256 netValue = calculateNetValue(positionInfo, prices);
// 🎯 Return consistent position data
return PositionData({
sizeInUsd: positionInfo.position.numbers.sizeInUsd, // βœ“ Validated
sizeInTokens: positionInfo.position.numbers.sizeInTokens, // βœ“ Validated
collateralAmount: positionInfo.position.numbers.collateralAmount,
netValue: netValue,
pnl: positionInfo.basePnlUsd,
isLong: positionInfo.position.flags.isLong
});
}

This validation should be integrated into all functions in PerpetualVault.sol that modify or read position data through VaultReader, ensuring consistent position sizing throughout the protocol's operations.

Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
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.

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
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!