Summary
getAccountMarginRequirementUsdAndUnrealizedPnlUsd() might return an incorrect unrealized pnl when targetMarketId > 0 and there is an existing position on the target market.
Vulnerability Details
While filling a market order, SettlementBranch::_fillOrder validates a margin requirement.
File: SettlementBranch.sol
408:
409:
410: (
411: UD60x18 requiredInitialMarginUsdX18,
412: UD60x18 requiredMaintenanceMarginUsdX18,
413: SD59x18 accountTotalUnrealizedPnlUsdX18
414: ) = tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(marketId, sizeDeltaX18);
415:
It gets accountTotalUnrealizedPnlUsdX18 using the getAccountMarginRequirementUsdAndUnrealizedPnlUsd() function and this pnl shows a total pnl of active positions.
In getAccountMarginRequirementUsdAndUnrealizedPnlUsd(), it calculates markPrice differently for targetMarketId.
File: TradingAccount.sol
224: if (targetMarketId != 0) {
225:
226: PerpMarket.Data storage perpMarket = PerpMarket.load(targetMarketId);
227:
228:
229: Position.Data storage position = Position.load(self.id, targetMarketId);
230:
231:
232: UD60x18 markPrice = perpMarket.getMarkPrice(sizeDeltaX18, perpMarket.getIndexPrice());
233:
234:
235: SD59x18 fundingFeePerUnit =
236: perpMarket.getNextFundingFeePerUnit(perpMarket.getCurrentFundingRate(), markPrice);
237:
238:
239:
240: UD60x18 notionalValueX18 = sd59x18(position.size).add(sizeDeltaX18).abs().intoUD60x18().mul(markPrice);
241:
242:
243: (UD60x18 positionInitialMarginUsdX18, UD60x18 positionMaintenanceMarginUsdX18) = Position
244: .getMarginRequirement(
245: notionalValueX18,
246: ud60x18(perpMarket.configuration.initialMarginRateX18),
247: ud60x18(perpMarket.configuration.maintenanceMarginRateX18)
248: );
249:
250:
251: SD59x18 positionUnrealizedPnl =
252: position.getUnrealizedPnl(markPrice).add(position.getAccruedFunding(fundingFeePerUnit));
For normal markets, it gets a markPrice using perpMarket.getMarkPrice(-position.size, indexPrice) at L278 because this price would be a fill price when we close the position.
But for targetMarketId, it uses markPrice = perpMarket.getMarkPrice(sizeDeltaX18, perpMarket.getIndexPrice()) to get the old position's pnl. This markPrice is a fill price of a new position but shouldn't be used for the old position because this price is not a closing price of the old position.
Here is an example.
Alice has a position of PosSize on a market and the unrealized pnl of the position will be calculated using markPrice = getMarkPrice(-PosSize, indexPrice).
Alice opens an additional position of 1 wei(we ignore minTradeSize for simplicity) on the same market.
Logically, her unrealized pnl won't be changed a lot because she has opened a very small position.
But during the settlement, getAccountMarginRequirementUsdAndUnrealizedPnlUsd() will be called and it will use markPrice = getMarkPrice(sizeDelta = 1, indexPrice) for her entire position(PosSize + 1).
So in getAccountMarginRequirementUsdAndUnrealizedPnlUsd(), her unrealized pnl might be calculated wrongly and it will affect the margin requirement validation.
Here is a detailed POC.
To execute the POC, we should set minTradeSizeX18 = 1 here.
The test function should be copied to test\integration\perpetuals\trading-account-branch\getAccountEquityUsd\getAccountEquityUsd.t.sol.
import {console} from "forge-std/console.sol";
function test_WrongMarkPriceForTragetIdIfTheSameMarketIdPositionExistsAlready(
uint256 initialMarginRate,
uint256 wstEthmarginValueUsd,
uint256 weEthmarginValueUsd,
bool isLong,
uint256 marketId
)
external
givenTheTradingAccountExists
{
MarketConfig memory fuzzMarketConfig = getFuzzMarketConfig(marketId);
initialMarginRate = 1e17;
wstEthmarginValueUsd = 1e23;
deal({ token: address(wstEth), to: users.naruto.account, give: wstEthmarginValueUsd * 2 });
weEthmarginValueUsd = 1e23;
deal({ token: address(weEth), to: users.naruto.account, give: weEthmarginValueUsd * 2});
uint128 tradingAccountId = createAccountAndDeposit(wstEthmarginValueUsd * 2, address(wstEth));
perpsEngine.depositMargin(tradingAccountId, address(weEth), weEthmarginValueUsd * 2);
UD60x18 marginCollateralValue = getPrice(
MockPriceFeed(marginCollaterals[WSTETH_MARGIN_COLLATERAL_ID].priceFeed)
).mul(ud60x18(wstEthmarginValueUsd * 2)).add(
getPrice(MockPriceFeed(marginCollaterals[WEETH_MARGIN_COLLATERAL_ID].priceFeed)).mul(
ud60x18(weEthmarginValueUsd * 2)
)
);
marginCollateralValue = marginCollateralValue.mul(ud60x18(7e17));
int128 sizeDelta = 5e22;
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: tradingAccountId,
marketId: fuzzMarketConfig.marketId,
sizeDelta: sizeDelta
})
);
bytes memory mockSignedReport =
getMockedSignedReport(fuzzMarketConfig.streamId, fuzzMarketConfig.mockUsdPrice);
address marketOrderKeeper = marketOrderKeepers[fuzzMarketConfig.marketId];
changePrank({ msgSender: marketOrderKeeper });
perpsEngine.fillMarketOrder(tradingAccountId, fuzzMarketConfig.marketId, mockSignedReport);
SD59x18 accountTotalUnrealizedPnlFirst = perpsEngine.getAccountTotalUnrealizedPnl(tradingAccountId);
sizeDelta = 1;
(SD59x18 marginBalanceUsdX18,,,,,) = perpsEngine.simulateTrade(tradingAccountId,
fuzzMarketConfig.marketId,
SettlementConfiguration.MARKET_ORDER_CONFIGURATION_ID,
sizeDelta);
changePrank({ msgSender: users.naruto.account });
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: tradingAccountId,
marketId: fuzzMarketConfig.marketId,
sizeDelta: sizeDelta
})
);
changePrank({ msgSender: marketOrderKeeper });
perpsEngine.fillMarketOrder(tradingAccountId, fuzzMarketConfig.marketId, mockSignedReport);
SD59x18 accountTotalUnrealizedPnlSecond = perpsEngine.getAccountTotalUnrealizedPnl(tradingAccountId);
console.log("UnrealizedPnlAfterFirstOrder");
console.logInt(accountTotalUnrealizedPnlFirst.intoInt256());
console.log("UnrealizedPnlAfterSecondOrder");
console.logInt(accountTotalUnrealizedPnlSecond.intoInt256());
console.log("SimulatedUnrealizedPnlBeforeSecondOrder");
console.logInt((marginBalanceUsdX18.sub(marginCollateralValue.intoSD59x18())).intoInt256());
}
Here is a test result.
├─ [0] console::log("UnrealizedPnlAfterFirstOrder") [staticcall]
│ └─ ← ()
├─ [0] console::logInt(0) [staticcall]
│ └─ ← ()
├─ [0] console::log("UnrealizedPnlAfterSecondOrder") [staticcall]
│ └─ ← ()
├─ [0] console::logInt(-12469659279800000 [-1.246e16]) [staticcall]
│ └─ ← ()
├─ [0] console::log("SimulatedUnrealizedPnlBeforeSecondOrder") [staticcall]
│ └─ ← ()
├─ [0] console::logInt(-561387670000904133200 [-5.613e20]) [staticcall]
As we can observe, UnrealizedPnlAfterFirstOrder and UnrealizedPnlAfterSecondOrder are very similar but SimulatedUnrealizedPnlBeforeSecondOrder shows a huge difference. (The output might be different due to the fuzz testing).
Impact
During an order settlement, it might validate a margin requirement wrongly as getAccountMarginRequirementUsdAndUnrealizedPnlUsd() might return an incorrect unrealized pnl.
Tools Used
Manual Review
Recommendations
While checking Synthetix as a reference, it uses mark price of original positions.
Synthetix calculates like this.
It gets currentAvailableMargin from isEligibleForLiquidation first. It means the pnl of an old position without considering the new position.
After that, it gets a fill price for the new position here. So this price is exactly same as the current markPrice for targetMarketId.
And then, it calculates order/settlement fees using this fill price.(Zaros handles inside _fillOrder() correctly)
After that, it gets a pnl of the final position(old size + new size) in calculateStartingPnl. And adds to currentAvailableMargin only if it's negative. It just uses (marketPrice - fillPrice) * totalPositionSize.
So with my example,
It should calculate the pnl of PosSize position like normal markets. So use getMarkPrice(-PosSize, indexPrice).
After that, it gets a mark price for the new position. getMarkPrice(1, offchainPrice).
And the starting pnl will be (indexPrice - fillPrice) * (PosSize + 1) and it should be added to the pnl if negative.