When a trader is already making some loss, trade size reduction causes zaros to erroneously remove the total loss instead of the proportionate loss based on the reduced percentage of the trade. This problem can lead to financial discrepancies and a loss of trust in the protocol's fairness.
When a trader reduces the size of their open position which is currently in loss, the protocol incorrectly removes the total loss associated with the entire position instead of adjusting the loss proportionately to the reduced trade size. The fillMarketOrder function calls the _fillOrderwhere the main login is executed. There is no check in the _fillOrderto execute logic specific to when a trader is trying to reduce the trading size. A new position is always created even if the trader is trying to reduce their position. The error is obvious on the zaros app where the app tells you the expected loss when you try to reduce a trade but the contract clears all the current unrealizedPnlUsdX18 and creates a new postion with a reduced trade size.
The image above shows a trader trying to reduce 10% of a trade and the expected loss that should be removed from the trade, but the smart contract does otherwise.
function _fillOrder(
uint128 tradingAccountId,
uint128 marketId,
uint128 settlementConfigurationId,
SD59x18 sizeDeltaX18,
UD60x18 fillPriceX18
)
internal
virtual
{
FillOrderContext memory ctx;
GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
SettlementConfiguration.Data storage settlementConfiguration =
SettlementConfiguration.load(marketId, settlementConfigurationId);
ctx.usdToken = globalConfiguration.usdToken;
ctx.isIncreasing = Position.isIncreasing(tradingAccountId, marketId, sizeDeltaX18.intoInt256().toInt128());
if (ctx.isIncreasing) {
globalConfiguration.checkMarketIsEnabled(marketId);
settlementConfiguration.checkIsSettlementEnabled();
}
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(tradingAccountId);
PerpMarket.Data storage perpMarket = PerpMarket.load(marketId);
perpMarket.checkTradeSize(sizeDeltaX18);
ctx.fundingRateX18 = perpMarket.getCurrentFundingRate();
ctx.fundingFeePerUnitX18 = perpMarket.getNextFundingFeePerUnit(ctx.fundingRateX18, fillPriceX18);
perpMarket.updateFunding(ctx.fundingRateX18, ctx.fundingFeePerUnitX18);
ctx.orderFeeUsdX18 = perpMarket.getOrderFeeUsd(sizeDeltaX18, fillPriceX18);
ctx.settlementFeeUsdX18 = ud60x18(uint256(settlementConfiguration.fee));
Position.Data storage oldPosition = Position.load(tradingAccountId, marketId);
ctx.oldPositionSizeX18 = sd59x18(oldPosition.size);
{
(
UD60x18 requiredInitialMarginUsdX18,
UD60x18 requiredMaintenanceMarginUsdX18,
SD59x18 accountTotalUnrealizedPnlUsdX18
) = tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(marketId, sizeDeltaX18);
ctx.shouldUseMaintenanceMargin = !ctx.isIncreasing && !ctx.oldPositionSizeX18.isZero();
ctx.requiredMarginUsdX18 =
ctx.shouldUseMaintenanceMargin ? requiredMaintenanceMarginUsdX18 : requiredInitialMarginUsdX18;
tradingAccount.validateMarginRequirement(
ctx.requiredMarginUsdX18,
tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18),
ctx.orderFeeUsdX18.add(ctx.settlementFeeUsdX18)
);
}
ctx.pnlUsdX18 =
oldPosition.getUnrealizedPnl(fillPriceX18).add(oldPosition.getAccruedFunding(ctx.fundingFeePerUnitX18));
ctx.newPosition = Position.Data({
size: ctx.oldPositionSizeX18.add(sizeDeltaX18).intoInt256(),
lastInteractionPrice: fillPriceX18.intoUint128(),
lastInteractionFundingFeePerUnit: ctx.fundingFeePerUnitX18.intoInt256().toInt128()
});
ctx.newPositionSizeX18 = sd59x18(ctx.newPosition.size);
(ctx.newOpenInterestX18, ctx.newSkewX18) =
perpMarket.checkOpenInterestLimits(sizeDeltaX18, ctx.oldPositionSizeX18, ctx.newPositionSizeX18);
perpMarket.updateOpenInterest(ctx.newOpenInterestX18, ctx.newSkewX18);
tradingAccount.updateActiveMarkets(marketId, ctx.oldPositionSizeX18, ctx.newPositionSizeX18);
if (ctx.newPositionSizeX18.isZero()) {
oldPosition.clear();
}
else {
if (ctx.newPositionSizeX18.abs().lt(sd59x18(int256(uint256(perpMarket.configuration.minTradeSizeX18))))) {
revert Errors.NewPositionSizeTooSmall();
}
oldPosition.update(ctx.newPosition);
}
if (ctx.pnlUsdX18.gt(SD59x18_ZERO)) {
ctx.marginToAddX18 = ctx.pnlUsdX18.intoUD60x18();
tradingAccount.deposit(ctx.usdToken, ctx.marginToAddX18);
LimitedMintingERC20(ctx.usdToken).mint(address(this), ctx.marginToAddX18.intoUint256());
}
@> tradingAccount.deductAccountMargin({
@> feeRecipients: FeeRecipients.Data({
@> marginCollateralRecipient: globalConfiguration.marginCollateralRecipient,
@> orderFeeRecipient: globalConfiguration.orderFeeRecipient,
@> settlementFeeRecipient: globalConfiguration.settlementFeeRecipient
@> }),
@> pnlUsdX18: ctx.pnlUsdX18.lt(SD59x18_ZERO) ? ctx.pnlUsdX18.abs().intoUD60x18() : UD60x18_ZERO,
@> orderFeeUsdX18: ctx.orderFeeUsdX18,
@> settlementFeeUsdX18: ctx.settlementFeeUsdX18
@> });
emit LogFillOrder(
msg.sender,
tradingAccountId,
marketId,
sizeDeltaX18.intoInt256(),
fillPriceX18.intoUint256(),
ctx.orderFeeUsdX18.intoUint256(),
ctx.settlementFeeUsdX18.intoUint256(),
ctx.pnlUsdX18.intoInt256(),
ctx.fundingFeePerUnitX18.intoInt256()
);
}
function testReducePositionSizeLeadToLoss() public {
changePrank({ msgSender: users.sasuke.account });
uint256 amount = 10000 * 10 ** 6;
deal({ token: address(usdc), to: users.sasuke.account, give: amount });
uint128 tradingAccountId = createAccountAndDeposit(amount, address(usdc));
int128 size = 1 * 10 ** 18;
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: tradingAccountId,
marketId : BTC_USD_MARKET_ID,
sizeDelta: size
})
);
changePrank({ msgSender: marketOrderKeepers[1] });
bytes memory firstMockSignedReport =
getMockedSignedReport(0x00037da06d56d083fe599397a4769a042d63aa73dc4ef57709d31e9971a5b439, MOCK_BTC_USD_PRICE);
perpsEngine.fillMarketOrder(tradingAccountId, BTC_USD_MARKET_ID, firstMockSignedReport);
changePrank({ msgSender: users.sasuke.account });
Position.State memory position = perpsEngine.getPositionState(tradingAccountId,
BTC_USD_MARKET_ID,
MOCK_BTC_USD_PRICE
);
UD60x18 initialEntryPrice = position.entryPriceX18;
SD59x18 positionSize = position.sizeX18;
uint256 updatedPrice = MOCK_BTC_USD_PRICE - MOCK_BTC_USD_PRICE / 20;
updateMockPriceFeed(BTC_USD_MARKET_ID, updatedPrice);
Position.State memory position2 = perpsEngine.getPositionState(tradingAccountId,
BTC_USD_MARKET_ID,
updatedPrice
);
SD59x18 updatedPositionSize = position2.sizeX18;
SD59x18 unrealizedPnlUsdX18 = position2.unrealizedPnlUsdX18;
size = 0.5 * 10 ** 18;
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: tradingAccountId,
marketId: BTC_USD_MARKET_ID,
sizeDelta: -size
})
);
changePrank({ msgSender: marketOrderKeepers[1] });
bytes memory firstMockSignedReport2 =
getMockedSignedReport(0x00037da06d56d083fe599397a4769a042d63aa73dc4ef57709d31e9971a5b439, updatedPrice);
perpsEngine.fillMarketOrder(tradingAccountId, BTC_USD_MARKET_ID, firstMockSignedReport2);
changePrank({ msgSender: users.sasuke.account });
Position.State memory position3 = perpsEngine.getPositionState(
tradingAccountId,
BTC_USD_MARKET_ID,
updatedPrice
);
SD59x18 updatedPositionSize1 = position3.sizeX18;
SD59x18 unrealizedPnlUsdX181 = position3.unrealizedPnlUsdX18;
assert(updatedPositionSize1.intoUint256() == updatedPositionSize.intoUint256() / 2);
assert(unrealizedPnlUsdX181.intoInt256() == unrealizedPnlUsdX18.intoInt256() / 2);
}
Update the loss calculation logic to ensure that only the proportionate loss corresponding to the reduced trade size is removed.