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

Flow State Manipulation Through Cancellation Redirect

Summary

The flow cancellation mechanism in PerpetualVault fails to properly reset the vault's state after cancellation. When a flow is cancelled, the contract transitions to LIQUIDATION state instead of NONE, potentially leaving the vault in an inconsistent state. When a keeper or authorized user calls cancelFlow() on the PerpetualVault. The function is intended to reset the vault's state when canceling an ongoing operation (like deposit or withdrawal). However, instead of clearing the flow state completely, it sets the flow to LIQUIDATION and schedules a FINALIZE action, creating an unexpected state transition.

This is similar to a traffic light system that, instead of resetting to red when malfunctioning, switches to flashing yellow, creating an ambiguous and potentially dangerous state.

Vulnerability Details

Imagine a train switching system where each track change must complete fully before the next can begin. The PerpetualVault's flow state management works similarly, it coordinates complex sequences of actions like deposits, withdrawals, and position changes. But with a flaw in how it handles interruptions. When a keeper calls cancelFlow(), instead of properly returning the system to its neutral state (FLOW.NONE), the code makes an unexpected decision, it forces the flow into FLOW.LIQUIDATION and schedules a FINALIZE action. This is like a train controller responding to an emergency stop by redirecting trains to the maintenance yard instead of clearing the tracks.

The flow starts when a user or keeper initiates an action, let's say a deposit. The vault enters FLOW.DEPOSIT state and begins processing. If something goes wrong and cancelFlow() is called, here's what happens: flow = FLOW.LIQUIDATION

function _cancelFlow() internal {
// First handles refunds for deposits/withdrawals
if (flow == FLOW.DEPOSIT) {
// Refund logic...
}
flow = FLOW.LIQUIDATION; // Key Issue: Forces an incorrect state
nextAction.selector = NextActionSelector.FINALIZE;
}

The vault now believes it's in liquidation mode, even though no actual liquidation event occurred. This creates a domino effect:

  1. The FINALIZE action expects to handle liquidation cleanup

  2. Future operations may be blocked or behave unexpectedly

  3. The vault's state machine becomes misaligned with reality

Impact

A vault in false liquidation state could:

  • Block new deposits when they should be allowed

  • Process withdrawals incorrectly thinking assets need liquidation

  • Confuse position management systems about the vault's true status

function _cancelFlow() internal {
// 🏦 Handle deposit cancellation
if (flow == FLOW.DEPOSIT) {
uint256 depositId = counter;
// 💸 Return user's funds
collateralToken.safeTransfer(depositInfo[depositId].owner, depositInfo[depositId].amount);
// 📊 Update accounting
totalDepositAmount = totalDepositAmount - depositInfo[depositId].amount;
// 📝 Remove from user's deposit list
EnumerableSet.remove(userDeposits[depositInfo[depositId].owner], depositId);
// ⛽ Try to refund gas fees
try IGmxProxy(gmxProxy).refundExecutionFee(
depositInfo[counter].owner,
depositInfo[counter].executionFee
) {} catch {}
// 🗑️ Clean up deposit data
delete depositInfo[depositId];
}
// 🏧 Handle withdrawal cancellation
else if (flow == FLOW.WITHDRAW) {
try IGmxProxy(gmxProxy).refundExecutionFee(
depositInfo[counter].owner,
depositInfo[counter].executionFee
) {} catch {}
}
// 🚨 Critical Bug: Incorrect state transition
flow = FLOW.LIQUIDATION; // ❌ Should be FLOW.NONE
nextAction.selector = NextActionSelector.FINALIZE; // ❌ Should be cleared
}

The vulnerability involves interaction between PerpetualVault.sol and GmxProxy.sol, with the state confusion potentially affecting operations through the VaultReader interface. The other contracts (IVaultReader.sol, IGmxProxy.sol, IGmxReader.sol, IPerpetualVault.sol) define the interfaces that make these interactions possible.

When a trading vault handles millions in user funds, its state transitions must be precise and predictable. The PerpetualVault's flow cancellation mechanism, intended to act as a circuit breaker during emergencies, instead creates a dangerous state confusion that could trap user funds and disrupt trading operations.

When something goes wrong, keepers can call cancelFlow() to halt operations. But instead of properly resetting, the vault makes a critical mistake, it forces itself into a LIQUIDATION state, like a circuit breaker that triggers a building evacuation instead of simply cutting power.

Let's walk through what happens in the real world. A user deposits funds, and the vault enters FLOW.DEPOSIT state. If the keeper needs to cancel this deposit, calling cancelFlow() should return everything to normal. Instead, the vault suddenly believes it's in liquidation mode. This false liquidation state prevents new deposits, confuses position management, and could interfere with actual liquidation events when they're truly needed.

Recommendations

When we cancel a flow, we need to truly reset the system, not redirect it to an emergency state. This means properly cleaning up the current operation and returning to a neutral state, ready for the next valid instruction

function _cancelFlow() internal {
// 🏦 Handle deposit cancellation
if (flow == FLOW.DEPOSIT) {
uint256 depositId = counter;
// 💸 Return user's funds
collateralToken.safeTransfer(depositInfo[depositId].owner, depositInfo[depositId].amount);
// 📊 Update accounting
totalDepositAmount = totalDepositAmount - depositInfo[depositId].amount;
// 📝 Remove from user's deposit list
EnumerableSet.remove(userDeposits[depositInfo[depositId].owner], depositId);
// ⛽ Try to refund gas fees via GMX proxy
try IGmxProxy(gmxProxy).refundExecutionFee(
depositInfo[counter].owner,
depositInfo[counter].executionFee
) {} catch {}
// 🗑️ Clean up deposit data
delete depositInfo[depositId];
}
// 🏧 Handle withdrawal cancellation
else if (flow == FLOW.WITHDRAW) {
// ⛽ Refund execution fees
try IGmxProxy(gmxProxy).refundExecutionFee(
depositInfo[counter].owner,
depositInfo[counter].executionFee
) {} catch {}
}
// 🔄 Reset state machine to neutral
delete flow; // Return to NONE state
delete nextAction; // Clear pending actions
delete flowData; // Reset flow data
// 📢 Emit event for tracking
emit FlowCancelled(); // Optional but recommended for monitoring
}
Updates

Lead Judging Commences

n0kto Lead Judge 9 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.

Support

FAQs

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

Give us feedback!