DeFiFoundry
60,000 USDC
View results
Submission Details
Severity: low
Valid

Trading accounts can exceed the maximum number of allowed open positions.

Summary

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.

Vulnerability Details

During market order creation (OrderBranch.createMarketOrder()), the tradingAccount.validatePositionsLimit() function verifies that the account is below the maximum number of open positions. However, this check occurs only during order creation and not during settlement. Consequently, the maximum number of active positions can be exceeded if offchain orders are used.

This issue did not arise in the previous audit because OrderBranch.createMarketOrder() was the sole entry point for order creation, allowing only one pending order at a time. However, with the addition of offchain orders, it is now possible to have one pending market order plus multiple offchain orders simultaneously, leading to the described issue.

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).

diff --git a/test/integration/perpetuals/settlement-branch/fillOffchainOrders/fillOffchainOrders.t.sol b/test/integration/perpetuals/settlement-branch/fillOffchainOrders/fillOffchainOrders.t.sol
index 23362ab..e45cc98 100644
--- a/test/integration/perpetuals/settlement-branch/fillOffchainOrders/fillOffchainOrders.t.sol
+++ b/test/integration/perpetuals/settlement-branch/fillOffchainOrders/fillOffchainOrders.t.sol
@@ -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() {
_;
}

Impact

Trading accounts can exceed the maximum number of allowed open positions.

Tools Used

Manual Review

Recommendation

Consider implementing a check during order settlement to ensure that the trading account remains below the maximum number of open positions.

Updates

Lead Judging Commences

inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

`maxPositionsPerAccount` may be exceeded by combining onchain and offchain orders

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!