Each trading account has a maximum limit on the number of open positions. For market orders, this limit is checked only during order creation (OrderBranch.createMarketOrder()), not during order settlement. As a result, it is possible to exceed the maximum number of open positions if multiple offchain orders are filled.
Consider a simple scenario of a global configuration allowing a maximum of one open position. Alice initially has no open positions but creates two offchain orders for markets A and B. After the offchain keeper transactions, Alice has 2 active positions, exceeding the configured maximum of 1. See the POC for this scenario below (Apply the diffs to test/integration/perpetuals/settlement-branch/fillOffchainOrders/fillOffchainOrders.t.sol and run forge build && forge test --match-test testMaxOrderPOC).
@@ -951,6 +951,179 @@ contract FillOffchainOrders_Integration_Test is Base_Test {
}
}
+ struct MaxOrdersPoc_Context {
+ MarketConfig marketConfig1;
+ MarketConfig marketConfig2;
+ uint256 initialMarginRate;
+ uint256 marginValueUsd;
+ uint128 tradingAccountId;
+ int128 sizeDelta;
+ uint128 perpsEngineMarkPrice;
+ uint128 markPrice;
+ bytes32 salt;
+ bytes32 digest;
+ uint8 v;
+ bytes32 r;
+ bytes32 s;
+ }
+
+ function testMaxOrderPOC() external {
+ // START OF SETUP
+ // Configuration setup
+ changePrank({ msgSender: users.owner.account });
+ perpsEngine.configureSystemParameters({
+ maxPositionsPerAccount: 1, // Setup max open positions to one!
+ marketOrderMinLifetime: MARKET_ORDER_MIN_LIFETIME,
+ liquidationFeeUsdX18: LIQUIDATION_FEE_USD,
+ marginCollateralRecipient: feeRecipients.marginCollateralRecipient,
+ orderFeeRecipient: feeRecipients.orderFeeRecipient,
+ settlementFeeRecipient: feeRecipients.settlementFeeRecipient,
+ liquidationFeeRecipient: users.liquidationFeeRecipient.account,
+ maxVerificationDelay: MAX_VERIFICATION_DELAY
+ });
+
+ changePrank({ msgSender: users.naruto.account });
+ MaxOrdersPoc_Context memory ctx;
+ ctx.marketConfig1 = getFuzzMarketConfig(1);
+ ctx.marketConfig2 = getFuzzMarketConfig(2);
+ ctx.initialMarginRate = MAX_MARGIN_REQUIREMENTS / 2;
+ ctx.marginValueUsd = USDC_MIN_DEPOSIT_MARGIN * 3;
+
+ deal({ token: address(usdc), to: users.naruto.account, give: ctx.marginValueUsd });
+ ctx.tradingAccountId = createAccountAndDeposit(ctx.marginValueUsd, address(usdc));
+
+ // First Offchain order setup
+ ctx.sizeDelta = fuzzOrderSizeDelta(
+ FuzzOrderSizeDeltaParams({
+ tradingAccountId: ctx.tradingAccountId,
+ marketId: ctx.marketConfig1.marketId,
+ settlementConfigurationId: SettlementConfiguration.MARKET_ORDER_CONFIGURATION_ID,
+ initialMarginRate: ud60x18(ctx.initialMarginRate),
+ marginValueUsd: ud60x18(ctx.marginValueUsd),
+ maxSkew: ud60x18(ctx.marketConfig1.maxSkew),
+ minTradeSize: ud60x18(ctx.marketConfig1.minTradeSize),
+ price: ud60x18(ctx.marketConfig1.mockUsdPrice),
+ isLong: true,
+ shouldDiscountFees: true
+ })
+ );
+
+ ctx.markPrice = perpsEngine.getMarkPrice(
+ ctx.marketConfig1.marketId, ctx.marketConfig1.mockUsdPrice, ctx.sizeDelta
+ ).intoUint128();
+
+ ctx.salt = bytes32(block.prevrandao);
+
+ ctx.digest = keccak256(
+ abi.encodePacked(
+ "\x19\x01",
+ perpsEngine.DOMAIN_SEPARATOR(),
+ keccak256(
+ abi.encode(
+ Constants.CREATE_OFFCHAIN_ORDER_TYPEHASH,
+ ctx.tradingAccountId,
+ ctx.marketConfig1.marketId,
+ ctx.sizeDelta,
+ ctx.markPrice,
+ false,
+ uint120(0),
+ ctx.salt
+ )
+ )
+ )
+ );
+
+ (ctx.v, ctx.r, ctx.s) = vm.sign({ privateKey: users.naruto.privateKey, digest: ctx.digest });
+
+ OffchainOrder.Data[] memory offchainOrdersMarketOne = new OffchainOrder.Data[](1);
+ offchainOrdersMarketOne[0] = OffchainOrder.Data({
+ tradingAccountId: ctx.tradingAccountId,
+ marketId: ctx.marketConfig1.marketId,
+ sizeDelta: ctx.sizeDelta,
+ targetPrice: ctx.markPrice,
+ shouldIncreaseNonce: false,
+ nonce: 0,
+ salt: ctx.salt,
+ v: ctx.v,
+ r: ctx.r,
+ s: ctx.s
+ });
+
+ // Second offchain order setup
+ ctx.sizeDelta = fuzzOrderSizeDelta(
+ FuzzOrderSizeDeltaParams({
+ tradingAccountId: ctx.tradingAccountId,
+ marketId: ctx.marketConfig2.marketId,
+ settlementConfigurationId: SettlementConfiguration.MARKET_ORDER_CONFIGURATION_ID,
+ initialMarginRate: ud60x18(ctx.initialMarginRate),
+ marginValueUsd: ud60x18(ctx.marginValueUsd),
+ maxSkew: ud60x18(ctx.marketConfig2.maxSkew),
+ minTradeSize: ud60x18(ctx.marketConfig2.minTradeSize),
+ price: ud60x18(ctx.marketConfig2.mockUsdPrice),
+ isLong: true,
+ shouldDiscountFees: true
+ })
+ );
+
+ ctx.markPrice = perpsEngine.getMarkPrice(
+ ctx.marketConfig2.marketId, ctx.marketConfig2.mockUsdPrice, ctx.sizeDelta
+ ).intoUint128();
+
+ ctx.salt = bytes32(block.prevrandao);
+
+ ctx.digest = keccak256(
+ abi.encodePacked(
+ "\x19\x01",
+ perpsEngine.DOMAIN_SEPARATOR(),
+ keccak256(
+ abi.encode(
+ Constants.CREATE_OFFCHAIN_ORDER_TYPEHASH,
+ ctx.tradingAccountId,
+ ctx.marketConfig2.marketId,
+ ctx.sizeDelta,
+ ctx.markPrice,
+ false,
+ uint120(0),
+ ctx.salt
+ )
+ )
+ )
+ );
+
+ (ctx.v, ctx.r, ctx.s) = vm.sign({ privateKey: users.naruto.privateKey, digest: ctx.digest });
+
+ OffchainOrder.Data[] memory offchainOrdersMarketTwo = new OffchainOrder.Data[](1);
+ offchainOrdersMarketTwo[0] = OffchainOrder.Data({
+ tradingAccountId: ctx.tradingAccountId,
+ marketId: ctx.marketConfig2.marketId,
+ sizeDelta: ctx.sizeDelta,
+ targetPrice: ctx.markPrice,
+ shouldIncreaseNonce: false,
+ nonce: 0,
+ salt: ctx.salt,
+ v: ctx.v,
+ r: ctx.r,
+ s: ctx.s
+ });
+ // END OF SETUP
+
+ changePrank({ msgSender: OFFCHAIN_ORDERS_KEEPER_ADDRESS });
+
+ bytes memory mockSignedReportOne =
+ getMockedSignedReport(ctx.marketConfig1.streamId, ctx.marketConfig1.mockUsdPrice);
+
+ bytes memory mockSignedReportTwo =
+ getMockedSignedReport(ctx.marketConfig2.streamId, ctx.marketConfig2.mockUsdPrice);
+
+ // Keeper fills offchain orders for marketId 1 and 2.
+ perpsEngine.fillOffchainOrders(ctx.marketConfig1.marketId, offchainOrdersMarketOne, mockSignedReportOne);
+ perpsEngine.fillOffchainOrders(ctx.marketConfig2.marketId, offchainOrdersMarketTwo, mockSignedReportTwo);
+
+ // Assert that open positions is 2. Therefore above the max value of 1 earlier.
+ uint256 numberOpenPositions = TradingAccountHarness(address(perpsEngine)).workaround_getActiveMarketsIdsLength(ctx.tradingAccountId);
+ assertEq(numberOpenPositions, 2);
+ }
+
modifier whenAllOffchainOrdersTargetPriceCanBeMatchedWithItsFillPrice() {
_;
}
Trading accounts can exceed the maximum number of allowed open positions.
Consider implementing a check during order settlement to ensure that the trading account remains below the maximum number of open positions.