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

Position State Desynchronization in Perpetual Vault

Summary

The PerpetualVault contract allows invalid state transitions where the position key (curPositionKey) and closed status (positionIsClosed) can become misaligned. This breaks the invariant that position keys must change when position states change, potentially leading to incorrect position tracking and management.

We verify that whenever a position's closed status changes, its position key must also change. However, the there's a scenario where the position state can change without a corresponding key update. This is like having a safety deposit box where the box number doesn't update when the contents are removed.

Looking at PerpetualVault.sol, position state changes occur in multiple places:

  • During order execution callbacks from GMX

  • When positions are liquidated

  • During withdrawal flows

The key issue arises in the position management logic where state updates aren't atomic. The contract can update positionIsClosed without properly updating curPositionKey, breaking the fundamental assumption that these states must change together.

When managing leveraged positions through GMX, the vault maintains two critical pieces of state: the position's closed status and its unique key. When one changes, the other must follow. But current implementation lets them fall out of sync.

Imagine a vault holding a leveraged ETH position. The position starts open with a valid key (let's say 0x123...). When closing this position through GMX, we update positionIsClosed to true, but the curPositionKey might not get cleared to zero. This state mismatch is like having a parking spot that shows both empty and occupied simultaneously.

See this example:

assert closedBefore != closedAfter => keyBefore != keyAfter;

This translates to: "If we close or open a position, its key must change." This scenario fails because our position management functions don't maintain this lock-step relationship.

This state inconsistency creates confusion in position tracking. When the keeper tries to execute the next trade, it might encounter:

  • Mismatched position states between our vault and GMX

  • Incorrect calculations of user shares and PnL

  • Failed order executions due to invalid position references

Vulnerability Details

The vault thinks a position is closed (positionIsClosed = true) while still holding its GMX position key. This is like having a parking spot that's both occupied and empty simultaneously. Looking at the codebase, the key affected functions are in PerpetualVault.sol:

  1. afterOrderExecution() - Handles GMX position updates and can modify position states

  2. afterLiquidationExecution() - Updates position states during liquidations

  3. _updateState() - Core internal function managing position state transitions

  4. _withdraw() - Position state changes during withdrawal flows

The issue manifests primarily in the interaction between these functions and GMX callbacks. For example, in afterOrderExecution(), position states are updated based on order results: Perp#L499-L502

if (orderResultData.orderType == Order.OrderType.MarketDecrease) {
uint256 sizeInUsd = vaultReader.getPositionSizeInUsd(curPositionKey);
if (sizeInUsd == 0) {
delete curPositionKey; // Key update
}
// Position closed status may be updated separately
}

The state transitions also flow through the keeper-executed actions in run() and runNextAction() when changing position types (long/short/close).

Impact

The main issue lies in how the PerpetualVault manages position state transitions during its complex interaction flows with GMX. When users deposit USDC into these leveraged vaults (1x-3x), the contract opens positions through GMX's perpetual markets. Each position has two vital pieces of information: its status (open/closed) and a unique identifier (positionKey).

When you check how the position lifecycle flows through multiple asynchronous steps. The keeper executes position changes, GMX processes them, and callbacks update the vault's state. This creates a delicate dance between positionIsClosed and curPositionKey, they must move in perfect sync, like partners in a waltz.

This means that when a position closes, its key must be zeroed out. Think of it like a hotel room, when a guest checks out, both the room status and their key card access must be updated together. But in our current implementation, these updates can fall out of step during complex flows like liquidations or keeper-executed position changes.

If a position shows as closed but maintains its old key, subsequent keeper actions could fail, leaving user funds trapped in limbo until manual intervention. For a protocol designed to automate leveraged trading, this breaks a fundamental trust assumption.

Let's look at the cariar of the issue in afterOrderExecution(). When processing a MarketDecrease order that closes a position, we check the size and clear the key: Perp#L501-L502

if (sizeInUsd == 0) {
delete curPositionKey; // Key cleared
}
// But position status might update separately through _updateState()

The position key and closed status can become misaligned during complex flows involving GMX callbacks, liquidations, and withdrawals.

// STATE VARIABLES AT RISK OF DESYNCHRONIZATION
bool public positionIsClosed; // Can become out of sync with curPositionKey
bytes32 public curPositionKey; // Should be 0 when positionIsClosed = true
// ENTRY POINTS WHERE DESYNC CAN OCCUR
function afterOrderExecution(...) external {
// Key State Change Point #1
if (orderResultData.orderType == Order.OrderType.MarketDecrease) {
if (sizeInUsd == 0) {
delete curPositionKey; // Key cleared here
}
// ... but positionIsClosed updated separately in _updateState()
}
}
function afterLiquidationExecution() external {
// Key State Change Point #2
if (sizeInTokens == 0) {
delete curPositionKey; // Similar desync risk
}
// Position status might not update immediately
}
function _updateState(bool _positionIsClosed, bool _isLong) internal {
// Key State Change Point #3
if (flow == FLOW.SIGNAL_CHANGE) {
positionIsClosed = _positionIsClosed; // Updates status without atomic key update
}
}
function _withdraw(uint256 depositId, ...) internal {
// Key State Change Point #4
// Relies on potentially desynced state
if (positionIsClosed) {
_handleReturn(0, true, false);
} else if (curPositionKey == bytes32(0)) { // These conditions could conflict
_handleReturn(0, true, false);
}
}

Tools Used

Manual

Recommendations

Treat these state changes as atomic operations, ensuring position keys and their status remain inseparable throughout the position's lifecycle.

// In PerpetualVault.sol
contract PerpetualVault {
// Atomic state management function
function _syncPositionState(
bool shouldClose,
bytes32 newKey,
MarketPrices memory prices
) internal {
// Validate state transition
require(
shouldClose == (newKey == bytes32(0)),
"Invalid position state transition"
);
// Atomic update of both states
positionIsClosed = shouldClose;
curPositionKey = newKey;
// Emit event for tracking
emit PositionStateChanged(shouldClose, newKey, prices);
}
// Update callback handlers to use atomic state management
function afterOrderExecution(...) external {
if (orderResultData.orderType == Order.OrderType.MarketDecrease) {
uint256 sizeInUsd = vaultReader.getPositionSizeInUsd(curPositionKey);
// Atomic state update
_syncPositionState(
sizeInUsd == 0,
sizeInUsd == 0 ? bytes32(0) : curPositionKey,
prices
);
}
}
function afterLiquidationExecution() external {
uint256 sizeInTokens = vaultReader.getPositionSizeInTokens(curPositionKey);
// Atomic state update
_syncPositionState(
sizeInTokens == 0,
sizeInTokens == 0 ? bytes32(0) : curPositionKey,
prices
);
}
function _updateState(bool _positionIsClosed, bool _isLong) internal {
if (flow == FLOW.SIGNAL_CHANGE) {
// Atomic state update
_syncPositionState(
_positionIsClosed,
_positionIsClosed ? bytes32(0) : curPositionKey,
prices
);
}
}
}

To ensure"

  1. Position state changes are always atomic through _syncPositionState

  2. State transitions are validated before execution

  3. Position key and closed status remain consistent

Updates

Lead Judging Commences

n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

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.