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

Cancelling a Flow after a Position Is Created Might Result in Inflation/Deflation of Shares

Vulnerability Details

In PerpetualVault a deposit or withdrawal flow can be cancelled via Keepers calling the cancelFlow function. The cancelFlow has the gmxLock modifier meaning that cancelFlow can be called anytime but not when gmx order request is on-going. Before _createIncrease/DecreasePosition and after it's executed fully.

But this could lead to situations where a flow is cancelled after a order is executed fully. And that might lead to some unintended results.

  • During a deposit flow if the flow is cancelled after the execution of _createIncreasePosition it could result in a inflation in the value of shares if the vault has enough collateral tokens acquired form the last action. Since cancelling the flow in a situation like that would result in a refund of the user's collateral but it does not decrease the totalShares value. Meaning that although the user is now not a part of the vault the shares minted to them still remain in the totalShares. Resulting in inflation in the value of shares.

  • During a withdrawal flow if the flow is cancelled after the execution of _createDecreasePosition, it could result in a situation where the position is not entirely closed and the vault now has the user's collateral that is to be returned plus any collateral amount resulting from the last action / settle action. But if the user calls withdraw again in this situation, the withdraw flow will execute resulting in another position of the withdrawer's amount being closed from the active position, and then the amount calculated in _handleReturn would result in a withdrawal amount a bit higher than during the previous withdrawal flow. Because the current balanceBeforeWithdrawal would not only include any collateral generated from last action and settle but also the collateral withdrawn during the previously cancelled flow.

Impact

During a deposit flow it could result in a inflation in share value and a deflation in share value during a withdrawal flow

PoC

During a deposit flow, the flow of action would be something like this:

function test_CancelDeposit_Call_After_Pos_Increases() external {
//setup
address alice = makeAddr("alice");
payable(alice).transfer(1 ether);
depositFixture(alice, 1e12);
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
address keeper = PerpetualVault(vault).keeper();
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);//1x SHORT
GmxOrderExecuted(true);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, data);
emit log_named_decimal_uint("Vault shares before cancel flow", PerpetualVault(vault).totalShares(), 8);
//
//A withdraw action before the deposit action causes enough indexTokens to enter the vault due to settle/ADL action
//Someone deposits to the open position after that
deal(alice, 1 ether);
depositFixture(alice, 1e8);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, data);
deal(address(PerpetualVault(vault).collateralToken()), vault, 1e10);//indexTokens Swapped to collateral during INCREASE_ACTION
GmxOrderExecuted(true); //increase position completed
(PerpetualVault.NextActionSelector selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 6);
bool isLock = PerpetualVault(vault).isLock();
assertEq(isLock, false);
uint256 ethBalBefore = alice.balance;
//keeper calls cancelFlow after a position is executed
//Keeper calls cancel flow after the next deposit has already increased the position, it passes because the collateral tokens generated in vault from the last withdraw action are enough to suffice the deposit amount of the new deposit
vm.prank(keeper);
PerpetualVault(vault).cancelFlow();
assertTrue(ethBalBefore < alice.balance);
(selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 6);
// uint8 isNextAction = uint8(PerpetualVault(vault).isNextAction());
// assertEq(isNextAction, 6);
uint8 flow = uint8(PerpetualVault(vault).flow());
assertEq(flow, 5);
emit log_named_decimal_uint("Vault Shares after cancel flow", PerpetualVault(vault).totalShares(), 8);
}
Logs:
Vault shares before cancel flow: 1000000000000.00000000
Vault Shares after cancel flow: 1000099186888.80175813

We notice that even though the deposit was cancelled successfully the vault generates extra shares, this would result in inflation in share value during future withdrawals since we withdraw pro-rata to the shares owned shares / totalShares leaving unclaimable collateral becuase of the extra deposit amount that came in even though the flow was cancelled.

During Withdrawal flow :

function test_CancelWithdraw_Call_After_Pos_Decreases() external {
console.log("Alice Deposits to the Vault");
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
address keeper = PerpetualVault(vault).keeper();
address alice = makeAddr("alice");
depositFixture(alice, 1e12);
(,uint256 aliceShares,,,,) = PerpetualVault(vault).depositInfo(1);
emit log_named_decimal_uint("Shares Minted when no pos open", aliceShares, 8);
emit log_named_decimal_uint("Total Collateral In Vault Initially", collateralToken.balanceOf(vault), 6);
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);//1x SHORT POSITION
PerpetualVault.FLOW flow = PerpetualVault(vault).flow();
assertEq(uint8(flow), 2);
GmxOrderExecuted(true);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));
////Bob also deposits ///////////////////////////////////////////////
console.log("Bob deposits during an open position");
address bob = makeAddr("bob");
deal(bob, 1 ether); //to cover for execution fees
depositFixture(bob, 1e12);
(PerpetualVault.NextActionSelector selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 1);//INCREASE ACTION
//keeper for nextAction call
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, data);
GmxOrderExecuted(true);//callback
(selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 6);//FINALIZE ACTION
//keeper for nextAction call
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));
////////////////////////////////////////////////////////////////////////////////
//ALice Withdraws
console.log("Alice tries to withdraw");
uint256 balanceBeforeWithdrawal = collateralToken.balanceOf(vault);
uint256 totalShares = PerpetualVault(vault).totalShares();
uint256[] memory depositIds = PerpetualVault(vault).getUserDeposits(alice);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(false);
uint256 lockTime = 1;
PerpetualVault(vault).setLockTime(lockTime);
vm.warp(block.timestamp + lockTime + 1);
payable(alice).transfer(1 ether);
vm.prank(alice);
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(alice, depositIds[0]);
GmxOrderExecuted(true);//settle callback
(selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 3);//WITHDRAW ACTION
bytes[] memory swapData = new bytes[](2);
swapData[0] = abi.encode(3390000000000000);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, swapData);
GmxOrderExecuted(true);//market decrease callback
uint256 withdrawAmount = collateralToken.balanceOf(vault) - balanceBeforeWithdrawal;
bool isLock = PerpetualVault(vault).isLock();
assertEq(isLock, false);
console.log("Keeper cancels withdraw after the market position is decreased");
//keeper calls cancelFlow after a position is executed
vm.prank(keeper);
PerpetualVault(vault).cancelFlow();
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));//finalize
//emit log_named_decimal_uint("Total Collateral In Vault When withdraw flow cancelled after pos decrased", collateralToken.balanceOf(vault), 6);
uint256 refundAmountInCancelFlow = withdrawAmount + balanceBeforeWithdrawal * aliceShares / totalShares;
emit log_named_decimal_uint("Refund Amount for Alice When Flow Cancelled", refundAmountInCancelFlow, 6);
//Alice withdraws again since the withdraw was cancelled
console.log("Alice Withdraws again after her withdraw was cancelled");
balanceBeforeWithdrawal = collateralToken.balanceOf(vault);
vm.prank(alice);
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(alice, depositIds[0]);
GmxOrderExecuted(true);//settle callback
(selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 3);//WITHDRAW ACTION
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, swapData);
GmxOrderExecuted(true);//market decrease callback
withdrawAmount = collateralToken.balanceOf(vault) - balanceBeforeWithdrawal;
//emit log_named_decimal_uint("Total Collateral In Vault after second withdraw call", collateralToken.balanceOf(vault), 6);
uint256 refundAmount= withdrawAmount + balanceBeforeWithdrawal * aliceShares / totalShares;
emit log_named_decimal_uint("Refund Amount When calling Withdrawal again", refundAmount, 6);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));//FINALIZE
}
Logs:
Alice Deposits to the Vault
Shares Minted when no pos open: 1000000000000.00000000
Total Collateral In Vault Initially: 1000000.000000
Bob deposits during an open position
Alice tries to withdraw
Keeper cancels withdraw after the market position is decreased
Refund Amount for Alice When Flow Cancelled: 994108.906352
Alice Withdraws again after her withdraw was cancelled
Refund Amount When calling Withdrawal again: 995377.193831

Here we notice, if the withdrawal flow is cancelled after a position has been decreased, and then the withdrawer tries to withdraw again they receive more collateral for the same amount of tokens. Resulting in deflation of share value.

Tools Used

Manual Review
Foundry

Recommendations

The only recommendation I could think of is to allow keepers to only call cacelFlow before _createIncreasePosition / _createDecreasePosition is executed and not after that. This could be done by adding a flag variable such as bool positionExecuted that keeps track of the position execution during a single flow and allowing the keepers to enter cancelFlow only when positionExecuted is false

Note :- I consider this a valid issue because it does not stem from a scenario where the keeper loses functionality or behaves maliciously. The keeper is executing its role as intended. Instead, the root cause lies in a fundamental logical flaw in how cancelFlow operates. This design flaw can lead to unintended consequences, regardless of the keeper's trustability (hence why the low), making it a issue that needs to be addressed.

Updates

Lead Judging Commences

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

finding_cancelFlow_can_be_called_after_order_execution_leading_to_disturb_shares_and_refund_too_many_fees

Likelihood: None/Very Low, when the keeper call cancelFlow after an order execution Impact: High, Inflation/deflation of total shares, and too many fees refunded.

Support

FAQs

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

Give us feedback!