Summary
This report details a vulnerability in the protocol where offchain orders can bypass the maximum trade per account check. As a result, users can have an unlimited number of trades, thereby breaking the protocol's invariant. This issue allows users to create positions beyond the specified limit.
Vulnerability Details
The vulnerability arises because offchain orders, when settled on-chain, do not include the necessary invariant check for the maximum trade limit per account before trade execution. Specifically, the maximum trade limit check is omitted in the `fillOrder` function and `fillOffchainOrder`.
`fillOffchainOrder` can be used to create Limit orders. Hence this Vulnerability exits.
### Code Analysis
The `validatePositionsLimit` function is intended to ensure that a trading account does not exceed the maximum number of active positions. This function is called in the context of creating market orders but is missing from the `fillOrder` execution path.
function validatePositionsLimit(Data storage self) internal view {
GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
uint256 maxPositionsPerAccount = globalConfiguration.maxPositionsPerAccount;
uint256 activePositionsLength = self.activeMarketsIds.length();
if (activePositionsLength >= maxPositionsPerAccount) {
revert Errors.MaxPositionsPerAccountReached(self.id, activePositionsLength, maxPositionsPerAccount);
}
}
The check above can be found when validating an onchain marketorder
function createMarketOrder(CreateMarketOrderParams calldata params) external {
-------------------------------------------------------------------------
TradingAccount.Data storage tradingAccount =
TradingAccount.loadExistingAccountAndVerifySender(params.tradingAccountId);
@audit>> ctx.isMarketWithActivePosition = tradingAccount.isMarketWithActivePosition(params.marketId);
if (!ctx.isMarketWithActivePosition) {
@audit>> enforce limit for new positions >> tradingAccount.validatePositionsLimit();
}
function fillOffchainOrders(
uint128 marketId,
OffchainOrder.Data[] calldata offchainOrders,
bytes calldata priceData
)
external
onlyOffchainOrdersKeeper(marketId)
{
----------------------------------------------------------------------
@audit>> enforce limits for new positions/Validates trades first before excecution >>
for (uint256 i; i < offchainOrders.length; i++) {
ctx.offchainOrder = offchainOrders[i];
if (ctx.offchainOrder.sizeDelta == 0) {
revert Errors.ZeroInput("offchainOrder.sizeDelta");
}
TradingAccount.Data storage tradingAccount =
TradingAccount.loadExisting(ctx.offchainOrder.tradingAccountId);
@audit>> Max trade per account not enforced, limits for new positions bypassed >>
if (marketId != ctx.offchainOrder.marketId) {
revert Errors.OrderMarketIdMismatch(marketId, ctx.offchainOrder.marketId);
}
if (ctx.offchainOrder.nonce != tradingAccount.nonce) {
revert Errors.InvalidSignedNonce(tradingAccount.nonce, ctx.offchainOrder.nonce);
}
ctx.structHash = keccak256(
abi.encode(
Constants.CREATE_OFFCHAIN_ORDER_TYPEHASH,
ctx.offchainOrder.tradingAccountId,
ctx.offchainOrder.marketId,
ctx.offchainOrder.sizeDelta,
ctx.offchainOrder.targetPrice,
ctx.offchainOrder.shouldIncreaseNonce,
ctx.offchainOrder.nonce,
ctx.offchainOrder.salt
)
);
if (tradingAccount.hasOffchainOrderBeenFilled[ctx.structHash]) {
revert Errors.OrderAlreadyFilled(ctx.offchainOrder.tradingAccountId, ctx.offchainOrder.salt);
}
ctx.signer = ECDSA.recover(
_hashTypedDataV4(ctx.structHash), ctx.offchainOrder.v, ctx.offchainOrder.r, ctx.offchainOrder.s
);
if (ctx.signer != tradingAccount.owner) {
revert Errors.InvalidOrderSigner(ctx.signer, tradingAccount.owner);
}
ctx.isBuyOrder = ctx.offchainOrder.sizeDelta > 0;
ctx.indexPriceX18 = ctx.isBuyOrder ? ctx.askX18 : ctx.bidX18;
ctx.fillPriceX18 = perpMarket.getMarkPrice(sd59x18(ctx.offchainOrder.sizeDelta), ctx.indexPriceX18);
ctx.isFillPriceValid = (ctx.isBuyOrder && ctx.offchainOrder.targetPrice <= ctx.fillPriceX18.intoUint256())
|| (!ctx.isBuyOrder && ctx.offchainOrder.targetPrice >= ctx.fillPriceX18.intoUint256());
if (!ctx.isFillPriceValid) {
continue;
}
if (ctx.offchainOrder.shouldIncreaseNonce) {
unchecked {
tradingAccount.nonce++;
}
}
tradingAccount.hasOffchainOrderBeenFilled[ctx.structHash] = true;
_fillOrder(
ctx.offchainOrder.tradingAccountId,
marketId,
SettlementConfiguration.OFFCHAIN_ORDERS_CONFIGURATION_ID,
sd59x18(ctx.offchainOrder.sizeDelta),
ctx.fillPriceX18
);
}
In the `fillOrder` function, this validation is missing also, allowing users to bypass the limit with offchain limit orders.
function \_fillOrder(
uint128 tradingAccountId,
uint128 marketId,
uint128 settlementConfigurationId,
SD59x18 sizeDeltaX18,
UD60x18 fillPriceX18
)
internal
virtual
{
@audit>> Max trade per account not enforced here also >>>
}
Impact
The absence of the maximum trade limit check in the `fillOrder` function allows users to bypass the protocol's invariant, resulting in the following potential impacts:
1. **Unlimited Trades**: Users can create an unlimited number of trades, ignoring the maximum limit set by the protocol.
2. **Market Manipulation**: Users can exploit this vulnerability to manipulate the market by creating excessive positions.
Tools Used
Manual Code Review
Recommendations
To mitigate this vulnerability, it is crucial to include the `validatePositionsLimit` check in the `fillOrder` function, ensuring that the maximum trade limit is enforced consistently across all order types.
### Code Fix
Add the following validation in the `_fillOffchainOrder` function:
function fillOffchainOrders(
uint128 marketId,
OffchainOrder.Data[] calldata offchainOrders,
bytes calldata priceData
)
external
onlyOffchainOrdersKeeper(marketId)
{
FillOffchainOrders_Context memory ctx;
SettlementConfiguration.Data storage settlementConfiguration =
SettlementConfiguration.load(marketId, SettlementConfiguration.OFFCHAIN_ORDERS_CONFIGURATION_ID);
PerpMarket.Data storage perpMarket = PerpMarket.load(marketId);
GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
(ctx.bidX18, ctx.askX18) =
settlementConfiguration.verifyOffchainPrice(priceData, globalConfiguration.maxVerificationDelay);
for (uint256 i; i < offchainOrders.length; i++) {
ctx.offchainOrder = offchainOrders[i];
if (ctx.offchainOrder.sizeDelta == 0) {
revert Errors.ZeroInput("offchainOrder.sizeDelta");
}
TradingAccount.Data storage tradingAccount =
TradingAccount.loadExisting(ctx.offchainOrder.tradingAccountId);
if (marketId != ctx.offchainOrder.marketId) {
revert Errors.OrderMarketIdMismatch(marketId, ctx.offchainOrder.marketId);
}
++ bool isMarketWithActivePosition;
++
++ isMarketWithActivePosition = tradingAccount.isMarketWithActivePosition(ctx.offchainOrder.marketId);
++
++
++
++
++ if (!isMarketWithActivePosition) {
++ tradingAccount.validatePositionsLimit();
++ }
if (ctx.offchainOrder.nonce != tradingAccount.nonce) {
revert Errors.InvalidSignedNonce(tradingAccount.nonce, ctx.offchainOrder.nonce);
}
ctx.structHash = keccak256(
abi.encode(
Constants.CREATE_OFFCHAIN_ORDER_TYPEHASH,
ctx.offchainOrder.tradingAccountId,
ctx.offchainOrder.marketId,
ctx.offchainOrder.sizeDelta,
ctx.offchainOrder.targetPrice,
ctx.offchainOrder.shouldIncreaseNonce,
ctx.offchainOrder.nonce,
ctx.offchainOrder.salt
)
);
if (tradingAccount.hasOffchainOrderBeenFilled[ctx.structHash]) {
revert Errors.OrderAlreadyFilled(ctx.offchainOrder.tradingAccountId, ctx.offchainOrder.salt);
}
ctx.signer = ECDSA.recover(
_hashTypedDataV4(ctx.structHash), ctx.offchainOrder.v, ctx.offchainOrder.r, ctx.offchainOrder.s
);
if (ctx.signer != tradingAccount.owner) {
revert Errors.InvalidOrderSigner(ctx.signer, tradingAccount.owner);
}
ctx.isBuyOrder = ctx.offchainOrder.sizeDelta > 0;
ctx.indexPriceX18 = ctx.isBuyOrder ? ctx.askX18 : ctx.bidX18;
ctx.fillPriceX18 = perpMarket.getMarkPrice(sd59x18(ctx.offchainOrder.sizeDelta), ctx.indexPriceX18);
ctx.isFillPriceValid = (ctx.isBuyOrder && ctx.offchainOrder.targetPrice <= ctx.fillPriceX18.intoUint256())
|| (!ctx.isBuyOrder && ctx.offchainOrder.targetPrice >= ctx.fillPriceX18.intoUint256());
if (!ctx.isFillPriceValid) {
continue;
}
if (ctx.offchainOrder.shouldIncreaseNonce) {
unchecked {
tradingAccount.nonce++;
}
}
tradingAccount.hasOffchainOrderBeenFilled[ctx.structHash] = true;
_fillOrder(
ctx.offchainOrder.tradingAccountId,
marketId,
SettlementConfiguration.OFFCHAIN_ORDERS_CONFIGURATION_ID,
sd59x18(ctx.offchainOrder.sizeDelta),
ctx.fillPriceX18
);
}
}