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

Position State Desynchronization Enables Invalid Trading

Summary

The position state tracking in PerpetualVault can become inconsistent when the positionIsClosed flag changes without a corresponding update to curPositionKey. This breaks the invariant that an open position must always have a valid position key, potentially leading to incorrect position management.

Whenever the position state (open/closed) changes, the position key must also change accordingly. The main issue arises in PerpetualVault.sol where position state transitions can occur in multiple functions like afterOrderExecution(), afterLiquidationExecution(), and _updateState(). The contract fails to maintain synchronized updates between positionIsClosed and curPositionKey.

In PerpetualVault.sol, position state is tracked through two key variables

  • positionIsClosed: Boolean indicating if a position is currently open

  • curPositionKey: bytes32 hash identifying the current position on GMX

The violation occurs when these get out of sync, particularly during complex flows involving GMX callbacks. For example, in afterOrderExecution(), the position state might change without properly updating or clearing the position key.

This state inconsistency could lead to:

  1. Incorrect position tracking

  2. Failed operations due to invalid position keys

  3. Potential loss of position control if the key becomes invalid while the position is marked as open

function afterLiquidationExecution() external {
// ENTRY POINT: Called by GMX via GmxProxy after liquidation ⚡
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
// STATE CHANGE #1: Pause new deposits
depositPaused = true;
// CRITICAL CHECK: Get position size from GMX via VaultReader
uint256 sizeInTokens = vaultReader.getPositionSizeInTokens(curPositionKey);
// VULNERABILITY POINT #1: State Desync
// Clears curPositionKey but doesn't update positionIsClosed
if (sizeInTokens == 0) {
delete curPositionKey; // Key deleted but position still marked as open!
}
// STATE CHANGE #2: Flow Management
if (flow == FLOW.NONE) {
// New flow path
flow = FLOW.LIQUIDATION;
nextAction.selector = NextActionSelector.FINALIZE;
} else if (flow == FLOW.DEPOSIT) {
// Store position size for deposit processing
flowData = sizeInTokens;
} else if (flow == FLOW.WITHDRAW) {
// Force withdrawal path
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
}
// EXIT POINT: Position key might be cleared but positionIsClosed unchanged!
}

Vulnerability Details

Two critical pieces must always move in sync: the positionIsClosed flag and the curPositionKey. Think of these as the lock and key to a safety deposit box, they should never be out of alignment.

But when you see how the system handles position transitions through the GMX protocol. When a user opens a position, the vault creates a unique position key and marks positionIsClosed as false. When closing, both should reset, but here's where things get absorbing.

The error is in the complex flow of callbacks and state updates. Let's walk through the scenario:

  1. A vault has an open position (positionIsClosed = false, valid curPositionKey)

  2. During liquidation handling in afterLiquidationExecution(), the position gets closed on GMX's side

  3. The vault updates positionIsClosed to true, but curPositionKey remains unchanged

This means that subsequent operations might reference an invalid or stale position key while thinking the position is still open. It's like having a key to a lock that's already been changed.

Impact

During normal operations, a trader deposits USDC, the keeper executes their desired leverage strategy, and the vault maintains a careful record of position states. But here's where things get tricky, during liquidations or failed GMX callbacks, the position key can become invalidated while the vault still thinks the position is open.

This means that subsequent operations could reference a phantom position. Think about a trader trying to adjust their leverage or withdraw funds, they'd be operating on position data that doesn't actually exist on GMX anymore. The vault would be like a GPS showing you're still on the highway when you've already taken the exit.

The impact goes beyond simple state confusion. With position keys and states misaligned, the vault could:

  • Execute trades against invalid positions

  • Calculate incorrect share values during withdrawals

  • Fail to properly distribute funding fees among depositors

Looking at the code, we can see exactly where this happens:

function afterOrderExecution(...) external {
// PATH 1: Market Decrease Order
if (orderResultData.orderType == Order.OrderType.MarketDecrease) {
uint256 sizeInUsd = vaultReader.getPositionSizeInUsd(curPositionKey);
if (sizeInUsd == 0) {
delete curPositionKey; // Key cleared but positionIsClosed not updated
}
// ... other logic
}
}
function afterLiquidationExecution() external {
// PATH 2: Liquidation Flow
uint256 sizeInTokens = vaultReader.getPositionSizeInTokens(curPositionKey);
if (sizeInTokens == 0) {
delete curPositionKey; // Same issue - key cleared without state update
}
// No update to positionIsClosed
}
function _updateState(bool _positionIsClosed, bool _isLong) internal {
// PATH 3: Normal State Updates
if (flow == FLOW.SIGNAL_CHANGE) {
positionIsClosed = _positionIsClosed; // Updates position state
if (_positionIsClosed == false) {
beenLong = _isLong;
}
}
// ... other state cleanup
}

My observation is that position state changes can happen through three different paths:

  1. Market order execution (via GMX callback)

  2. Liquidation handling

  3. Normal state updates

Only the third path properly synchronizes positionIsClosed with curPositionKey. The other two paths can leave these critical state variables misaligned.

This interaction between PerpetualVault.sol and GmxProxy.sol (via callbacks) creates the vulnerability surface, while VaultReader.sol provides the position data that influences these state transitions.

Tools Used

Manual

Recommendations

Consider treating position state updates as atomic operations, ensuring the vault's view of positions always matches reality on GMX. This maintains the critical invariant that "PositionKey is 0 when no position exists" while preserving the vault's ability to manage leveraged positions safely.

// In PerpetualVault.sol
contract PerpetualVault {
// Add a new internal function for atomic state updates
function _syncPositionState(bytes32 key, bool isClosed) internal {
// ATOMIC UPDATE: Always update both state variables together
if (isClosed) {
delete curPositionKey;
positionIsClosed = true;
} else {
curPositionKey = key;
positionIsClosed = false;
}
emit PositionStateSync(key, isClosed); // Add tracking
}
// Modify existing functions to use atomic updates
function afterOrderExecution(...) external {
if (orderResultData.orderType == Order.OrderType.MarketDecrease) {
uint256 sizeInUsd = vaultReader.getPositionSizeInUsd(curPositionKey);
if (sizeInUsd == 0) {
_syncPositionState(bytes32(0), true); // Atomic update
}
}
}
function afterLiquidationExecution() external {
uint256 sizeInTokens = vaultReader.getPositionSizeInTokens(curPositionKey);
if (sizeInTokens == 0) {
_syncPositionState(bytes32(0), true); // Atomic update
}
}
function _updateState(bool _positionIsClosed, bool _isLong) internal {
if (flow == FLOW.SIGNAL_CHANGE) {
_syncPositionState(
_positionIsClosed ? bytes32(0) : curPositionKey,
_positionIsClosed
); // Atomic update
}
}
}

To ensure that position state changes are always handled atomically through a dedicated function, maintaining consistency between positionIsClosed and curPositionKey. The _updatePositionState function becomes the single source of truth for position state transitions.

  1. Position state changes are always atomic through _syncPositionState

  2. All paths that modify position state use the same synchronized update mechanism

  3. The invariant "PositionKey is 0 when no position exists" is enforced at the implementation level

The interaction between PerpetualVault and GmxProxy remains unchanged, but now position state transitions are guaranteed to maintain consistency.

Updates

Lead Judging Commences

n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
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.

invalid_liquidation_do_not_reset_positionIsClosed

"// keep the positionIsClosed value so that let the keeper be able to create an order again with the liquidated fund" Liquidation can send some remaining tokens. No real impact here.

Support

FAQs

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