Summary
accountTotalUnrealizedPnlUsdX18 is not calculated correctly if the position sign is going to be flipped during filling a new order. This leads to larger calculation of unrealized loss, making the users over charged.
Vulnerability Details
When filling the order, accountTotalUnrealizedPnlUsdX18 is calculated as (ignoring accruedFunding for simplicity):
accountTotalUnrealizedPnlUsdX18 = oldPositionSize * (markPrice - lastInteractionPrice)
https://github.com/Cyfrin/2024-07-zaros/blob/main/src/perpetuals/leaves/TradingAccount.sol#L252
https://github.com/Cyfrin/2024-07-zaros/blob/main/src/perpetuals/leaves/Position.sol#L135
In the above equation, the markPrice is calculated based on the sizeDelta and indexPrice.
https://github.com/Cyfrin/2024-07-zaros/blob/main/src/perpetuals/leaves/TradingAccount.sol#L232
It means that:
If an old long position is going to be increased in size (buy order), accountTotalUnrealizedPnlUsdX18 would be positive because oldPosition is positive, and markPrice > lastInteractionPrice.
If an old long position is going to be decreased in size (sell order), accountTotalUnrealizedPnlUsdX18 would be negative because oldPosition is positive, and markPrice < lastInteractionPrice.
If an old short position is going to be increased in size (sell order), accountTotalUnrealizedPnlUsdX18 would be positive because oldPosition is negative, and markPrice < lastInteractionPrice.
If an old short position is going to be decreased in size (buy order), accountTotalUnrealizedPnlUsdX18 would be negative because oldPosition is negative, and markPrice > lastInteractionPrice.
So far, everything is good.
The issue is when an old short position is going to change sign, in other words, when the sizeDelta is higher than the old short position size. So, the short position changes to long postion. In this case accountTotalUnrealizedPnlUsdX18 would be negative because oldPosition is negative, and markPrice > lastInteractionPrice. But, the value of accountTotalUnrealizedPnlUsdX18 is over calculated and over charges the user unfairly. Because:
accountTotalUnrealizedPnlUsdX18 = oldShortPositionSize * (markPrice as a result Of SizeDelta - lastInteractionPrice)
While, if the account first creates an order to close the short position, and then creates another long position, the accountTotalUnrealizedPnlUsdX18 would be:
accountTotalUnrealizedPnlUsdX18 = oldShortPositionSize * (lastInteractionPrice - lastInteractionPrice) + sizeDelta * (0 - 0) = 0
It is expected that accountTotalUnrealizedPnlUsdX18 for both cases be equal to each other because the final state of the protocol and market would be the same.
In summary, when the sign of a position is flipped, the uPnl should be considered as if the old position is closed, and then a new position is created. This is not implemented properly in the protocol.
PoC1
In the following test, the account has a short position with size -2000, and later creates and fill a long order with size 90_000. The console.log shows that accountTotalUnrealizedPnlUsdX18 is equal to -8800e18, equivalent to 8800$ loss. The final collateral balance is equal to 918090e18 equivalent to 918_090.
function test_uPnLInOneStepSignFlipping()
external
givenTheSenderIsTheKeeper
givenTheMarketOrderExists
givenThePerpMarketIsEnabled
givenTheSettlementStrategyIsEnabled
givenTheReportVerificationPasses
whenTheMarketOrderIdMatches
givenTheDataStreamsReportIsValid
givenTheAccountWillMeetTheMarginRequirement
givenTheMarketsOILimitWontBeExceeded
{
TestFuzz_GivenThePnlIsPositive_Context memory ctx;
ctx.fuzzMarketConfig = getFuzzMarketConfig(ETH_USD_MARKET_ID);
uint256 marginValueUsd = 1_000_000e18;
ctx.marketOrderKeeper = marketOrderKeepers[ctx.fuzzMarketConfig.marketId];
deal({ token: address(usdz), to: users.naruto.account, give: marginValueUsd });
ctx.tradingAccountId = createAccountAndDeposit(marginValueUsd, address(usdz));
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: int128(-2000e18)
})
);
ctx.firstMockSignedReport =
getMockedSignedReport(ctx.fuzzMarketConfig.streamId, ctx.fuzzMarketConfig.mockUsdPrice);
changePrank({ msgSender: ctx.marketOrderKeeper });
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.firstMockSignedReport);
changePrank({ msgSender: users.naruto.account });
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: 90_000e18
})
);
changePrank({ msgSender: ctx.marketOrderKeeper });
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.firstMockSignedReport);
UD60x18 collat = perpsEngine.getAccountMarginCollateralBalance(ctx.tradingAccountId, address(usdz));
console.log("collateral balance: ", unwrap(collat));
}
PoC2
In the following test, the account has a short position with size -2000, and later creates and fill two orders. First, a long order with size 2_000 to close the old short order, second, a long order with size 88_000. The console.log shows that total accountTotalUnrealizedPnlUsdX18 for both orders is equal to 0, equivalent to no loss. The final collateral balance is equal to 926884.48e18 equivalent to 926_884$.
Comparing this value with collateral balance in PoC1 shows that the difference is almost 926_884 - 918_090 = 8794$. This difference is because of the unfair uPnl calcualted in PoC1 as well as 6$ as the order/settlement fee.
function test_uPnLInTwoStepsSignFlipping()
external
givenTheSenderIsTheKeeper
givenTheMarketOrderExists
givenThePerpMarketIsEnabled
givenTheSettlementStrategyIsEnabled
givenTheReportVerificationPasses
whenTheMarketOrderIdMatches
givenTheDataStreamsReportIsValid
givenTheAccountWillMeetTheMarginRequirement
givenTheMarketsOILimitWontBeExceeded
{
TestFuzz_GivenThePnlIsPositive_Context memory ctx;
ctx.fuzzMarketConfig = getFuzzMarketConfig(ETH_USD_MARKET_ID);
uint256 marginValueUsd = 1_000_000e18;
ctx.marketOrderKeeper = marketOrderKeepers[ctx.fuzzMarketConfig.marketId];
deal({ token: address(usdz), to: users.naruto.account, give: marginValueUsd });
ctx.tradingAccountId = createAccountAndDeposit(marginValueUsd, address(usdz));
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: int128(-2000e18)
})
);
ctx.firstMockSignedReport =
getMockedSignedReport(ctx.fuzzMarketConfig.streamId, ctx.fuzzMarketConfig.mockUsdPrice);
changePrank({ msgSender: ctx.marketOrderKeeper });
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.firstMockSignedReport);
changePrank({ msgSender: users.naruto.account });
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: 2000e18
})
);
changePrank({ msgSender: ctx.marketOrderKeeper });
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.firstMockSignedReport);
changePrank({ msgSender: users.naruto.account });
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: 88_000e18
})
);
changePrank({ msgSender: ctx.marketOrderKeeper });
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.firstMockSignedReport);
UD60x18 collat = perpsEngine.getAccountMarginCollateralBalance(ctx.tradingAccountId, address(usdz));
console.log("collateral balance: ", unwrap(collat));
}
Impact
Tools Used
Recommendations
When the sign of a position is changed, the uPnl should be considered as if the old position is closed, and then a new position is created.