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

Entire negative PnL value deducted from withdrawer's collateralDeltaAmount instead of a proportional value

Description

During a withdrawal flow we have a call to _withdraw() which calls getPnl():

File: contracts/PerpetualVault.sol
1089: function _withdraw(uint256 depositId, bytes memory metadata, MarketPrices memory prices) internal {
1090: uint256 shares = depositInfo[depositId].shares;
1091: if (shares == 0) {
1092: revert Error.ZeroValue();
1093: }
1094:
1095: if (positionIsClosed) {
1096: _handleReturn(0, true, false);
1097: } else if (_isLongOneLeverage(beenLong)) { // beenLong && leverage == BASIS_POINTS_DIVISOR
1098: uint256 swapAmount = IERC20(indexToken).balanceOf(address(this)) * shares / totalShares;
1099: nextAction.selector = NextActionSelector.SWAP_ACTION;
1100: // abi.encode(swapAmount, swapDirection): if swap direction is true, swap collateralToken to indexToken
1101: nextAction.data = abi.encode(swapAmount, false);
1102: } else if (curPositionKey == bytes32(0)) { // vault liquidated
1103: _handleReturn(0, true, false);
1104: } else {
1105: IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(curPositionKey, prices);
1106: uint256 collateralDeltaAmount = positionData.collateralAmount * shares / totalShares;
1107: uint256 sizeDeltaInUsd = positionData.sizeInUsd * shares / totalShares;
1108: // we always charge the position fee of negative price impact case.
1109: uint256 feeAmount = vaultReader.getPositionFeeUsd(market, sizeDeltaInUsd, false) / prices.shortTokenPrice.max;
1110:@---> int256 pnl = vaultReader.getPnl(curPositionKey, prices, sizeDeltaInUsd);
1111: if (pnl < 0) {
1112:@---> collateralDeltaAmount = collateralDeltaAmount - feeAmount - uint256(-pnl) / prices.shortTokenPrice.max;
1113: } else {
1114: collateralDeltaAmount = collateralDeltaAmount - feeAmount;
1115: }
1116: uint256 acceptablePrice = abi.decode(metadata, (uint256));
1117: _createDecreasePosition(collateralDeltaAmount, sizeDeltaInUsd, beenLong, acceptablePrice, prices);
1118: }
1119: }

This pnl is deducted from collateralDeltaAmount on L1112.

If we see getPnl():

File: contracts/VaultReader.sol
171: function getPnl(
172: bytes32 key,
173: MarketPrices memory prices,
174: uint256 sizeDeltaUsd
175: ) external view returns (int256) {
176: uint256 sizeInTokens = getPositionSizeInUsd(key);
177: if (sizeInTokens == 0) return 0;
178:
179: PositionInfo memory positionInfo = gmxReader.getPositionInfo(
180: address(dataStore),
181: referralStorage,
182: key,
183: prices,
184:@---> sizeDeltaUsd,
185: address(0),
186:@---> true
187: );
188:
189: return positionInfo.pnlAfterPriceImpactUsd;
190: }

We see that GMX's gmxReader.getPositionInfo() is called with last param as true which if we see in GMX's implementation, corresponds to usePositionSizeAsSizeDeltaUsd. We further see that when usePositionSizeAsSizeDeltaUsd = true, the passed param sizeDeltaUsd is ignored and the entire position size is considered instead:

File: contracts/reader/ReaderPositionUtils.sol
178: function getPositionInfo(
179: DataStore dataStore,
180: IReferralStorage referralStorage,
181: Position.Props memory position,
182: MarketUtils.MarketPrices memory prices,
183: uint256 sizeDeltaUsd,
184: address uiFeeReceiver,
185:@---> bool usePositionSizeAsSizeDeltaUsd
186: ) internal view returns (PositionInfo memory) {
187: if (position.account() == address(0)) {
188: revert Errors.EmptyPosition();
189: }
190:
191: PositionInfo memory positionInfo;
192: GetPositionInfoCache memory cache;
193:
194: positionInfo.position = position;
195: cache.market = MarketStoreUtils.get(dataStore, positionInfo.position.market());
196: cache.collateralTokenPrice = MarketUtils.getCachedTokenPrice(positionInfo.position.collateralToken(), cache.market, prices);
197:
198:@---> if (usePositionSizeAsSizeDeltaUsd) {
199:@---> sizeDeltaUsd = positionInfo.position.sizeInUsd();
200: }
201:
// ... Rest of the code

Hence, the withdrawer is burdened with the entire negative PnL instead of their proportional amount.

Impact

  • Withdrawer takes a greater than expected loss.

  • Additionally, a few lines later inside _withdraw() we have calls to _createDecreasePosition() --> vaultReader.willPositionCollateralBeInsufficient(..., collateralDeltaAmount) which will now estimate this incorrectly.

Mitigation

getPnl() seems to have been called only from within _withdraw(), so we can modify the function itself:

function getPnl(
bytes32 key,
MarketPrices memory prices,
uint256 sizeDeltaUsd
) external view returns (int256) {
uint256 sizeInTokens = getPositionSizeInUsd(key);
if (sizeInTokens == 0) return 0;
PositionInfo memory positionInfo = gmxReader.getPositionInfo(
address(dataStore),
referralStorage,
key,
prices,
sizeDeltaUsd,
address(0),
- true
+ false
);
return positionInfo.pnlAfterPriceImpactUsd;
}
Updates

Lead Judging Commences

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

finding_getPnL_use_usePositionSizeAsSizeDeltaUsd_and_substract_it

Likelihood: Medium/High, every withdrawal from a short or leveraged vault that is not liquidated and has a negative PnL. Impact: High, subtract collateralDeltaAmount from the entire PnL of the position instead of the delta amount PnL. DoS or loss of funds

Support

FAQs

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

Give us feedback!