DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: high
Valid

State not updated properly after a 1x Long deposit and left in a state that wont allow deposits or withdraws

Summary

PerpetualVaulthas logic to account for every different type of situation / deposit type, depending on the user action, the current state of the position, and the keeper inputs -> there are several different moving pieces and many different code paths that can be executed to fulfill a deposit.

However, when a user deposits when the strategy is currently open, 1x Long Leverage-> there is a situation where the state is not updated properly and left in a state that will not allow any further depositsor withdraws.

Vulnerability Details

Lets follow the exact path of a user depositing, when the protocol position is currently: openand 1x long leverage:

  • User calls deposit -> flowis set to FLOW.DEPOSIT

    function deposit(uint256 amount) external nonReentrant payable {
    _noneFlow();
    if (depositPaused == true) {
    revert Error.Paused();
    }
    if (amount < minDepositAmount) {
    revert Error.InsufficientAmount();
    }
    if (totalDepositAmount + amount > maxDepositAmount) {
    revert Error.ExceedMaxDepositCap();
    }
    -> flow = FLOW.DEPOSIT;
    collateralToken.safeTransferFrom(msg.sender, address(this), amount);
    counter++;
    depositInfo[counter] = DepositInfo(amount, 0, msg.sender, 0, block.timestamp, address(0));
    totalDepositAmount += amount;
    EnumerableSet.add(userDeposits[msg.sender], counter);
    if (positionIsClosed) {
    MarketPrices memory prices;
    _mint(counter, amount, false, prices);
    _finalize(hex'');
    } else {
    _payExecutionFee(counter, true);
    // mint share token in the NextAction to involve off-chain price data and improve security
    -> nextAction.selector = NextActionSelector.INCREASE_ACTION;
    nextAction.data = abi.encode(beenLong);

becasue, NextActionSelector.INCREASE.ACTION-> the function runNextActionis called:

function runNextAction(MarketPrices memory prices, bytes[] memory metadata) external nonReentrant gmxLock {
_onlyKeeper();
Action memory _nextAction = nextAction;
delete nextAction;
if (_nextAction.selector == NextActionSelector.INCREASE_ACTION) {
(bool _isLong) = abi.decode(_nextAction.data, (bool));
if (_isLongOneLeverage(_isLong)) {
-> _runSwap(metadata, true, prices);
}
  • Here, the function accurately deletes nextAction-> which sets NEXT_ACTION = NONE

  • The last line of code of this function that is executed is _runSwap

  • The important piece here is that the inputs are generated off-chain and the keeper calls this function and inputs those values. Of those values is metadata

  • metadatacan include both swap parameters for dexSwapand gmxSwapor just 1 of them. Since it is 1x long leverage, there will only be a swap done

For this situation, the keeper only sends the dexSwapfor the metadata, meaning the entire swap will only take place on dexSwap-> which is the preferred method of the protocol. The inline documentation also reaffirms that there can be either both swap paths sent in or just 1 - the code itself also has logic to handle the situation when both gmxSwapand dexSwapare sent in, or just 1 of them.

_runSwapis executed, passing in ONLY dexSwapfor the metadata

/**
* @dev Swap data is an array of swap data and is passed from an off-chain script.
* It could contain only Paraswap data, GMX swap data, or both of them.
* If both are included, the first element of the array should always be Paraswap.
* @param metadata Bytes array that includes swap data.
* @param isCollateralToIndex Direction of swap. If true, swap `collateralToken` to `indexToken`.
* @return completed If true, the swap action is completed; if false, the swap action will continue.
*/
function _runSwap(bytes[] memory metadata, bool isCollateralToIndex, MarketPrices memory prices) internal returns (bool completed) {
else {
if (metadata.length != 1) {
revert Error.InvalidData();
}
(PROTOCOL _protocol, bytes memory data) = abi.decode(metadata[0], (PROTOCOL, bytes));
if (_protocol == PROTOCOL.DEX) {
uint256 outputAmount = _doDexSwap(data, isCollateralToIndex);
-> // update global state
if (flow == FLOW.DEPOSIT) {
// last `depositId` equals with `counter` because another deposit is not allowed before previous deposit is completely processed
-> _mint(counter, outputAmount + swapProgressData.swapped, true, prices);

As can be seen in the function above, swapProgressDatais used (and is also never deleted / reset)

The FINAL line of code that is executed in this function under this scenario, is _mint-> becasue the current flowis still FLOW.DEPOSIT

The inline documentation before calling _mintalso shows that it is expected that the global state will be updated (which it isnt)

** THE FINAL AND END OF THIS CODE PATH -> _mint

all of the inline documentation is original, i did not modify or edit it at all. As can be seen, this is the end of deposit flow

/**
-> * @notice this function is an end of deposit flow.
* @dev should update all necessary global state variables
*
* @param depositId `depositId` of mint operation
* @param amount actual deposit amount. if `_isLongOneLeverage` is `true`, amount of `indexToken`, or amount of `collateralToken`
*/
function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
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;
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[counter].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
}
}
emit Minted(depositId, depositInfo[depositId].owner, _shares, amount);

At the end of this, the user is accounted for their shares and the totalShares is updated to include the new shares, the user also gets refunded any excess fee (becasue they did not go through GMX for the swap)

BUT THAT IS IT, THATS THE END OF THE PATH. Global state variables left populated and never deleted include:

  • flow-> it is still FLOW.DEPOSIT

  • swapProgressDatais never deleted

Impact

Users will not be able to depositor withdrawbecasue flowis still FLOW.DEPOSIT

They wont be able to, becasue, at the beginning of the depositand withdrawfunctions, there is this check to ensure there is no active flow:

function deposit(uint256 amount) external nonReentrant payable {
_noneFlow();
function withdraw(address recipient, uint256 depositId) public payable nonReentrant {
_noneFlow();
// we prevent user interactions while a flow is still being in progress.
function _noneFlow() internal view {
if (flow != FLOW.NONE) {
revert Error.FlowInProgress();
}
}

Also, the documentation in the contest page says that a key invariant-> is that swapProgressDatashould always be empty after an action is completed.

Tools Used

Manual Review

Recommendations

All of the other scenarios handle updating the state changes after completing an action by either calling handleReturnor updateState-> and updateStatehas logic that is specific to handle this scenario's code path , but it is never called!

nextActionwas deleted within the code path, therefore it is NextActionSelector.NO_ACTION-> which is exactly what this function handles. IF called, this will update the state PERFECTLY.

  • delete flow

  • delete swapProgressData

Which are the EXACT variables that need to be updated and are left populated.

Call updateStateafter the _mintand update the state properly.

function _updateState(
bool _positionIsClosed,
bool _isLong
) internal {
-> if (nextAction.selector == NextActionSelector.NO_ACTION) {
if (_isLongOneLeverage(_isLong)) {
delete flowData;
-> delete flow;
} else {
nextAction.selector = NextActionSelector.FINALIZE;
}
}
-> delete swapProgressData;
Updates

Lead Judging Commences

n0kto Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_deposit_1x_long_dex_positionIsOpened_DoS_Flow

Likelihood: Medium/High, - Leverage = 1x - beenLong = True - positionIsClosed = False - Metadata → 1 length and Dex Swap Impact: Medium/High, DoS on any new action before the admin uses setVaultState Since this seems to be the most probable path for a 1x PerpVault, this one deserves a High.

Support

FAQs

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