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

Settlement fills liquidatable Market Orders

Summary

The settlement process lacks a critical check to ensure that the position being filled is not liquidatable. Although the "isLiquidatable" check may pass during order creation, sudden price movements and the asynchronous nature of order fulfillment via keepers can lead to a scenario where the order becomes liquidatable by the time it is filled. This means the filled position can immediately become liquidatable, causing an instant loss to the user.

Vulnerability Details

During order creation, the order is simulated using the simulateTrade function, which ensures the simulated trade is not liquidatable, as shown below:

https://github.com/Cyfrin/2024-07-zaros/blob/69ccf428b745058bea08804b3f3d961d31406ba8/src/perpetuals/branches/OrderBranch.sol#L133-L134

if (TradingAccount.isLiquidatable(ctx.previousRequiredMaintenanceMarginUsdX18, marginBalanceUsdX18)) {
revert Errors.AccountIsLiquidatable(tradingAccountId);
}

This check ensures that once the trade is filled upon settlement, it is not immediately liquidatable, preventing instant loss to the user.

However, this validation is missing during the settlement process. Due to the asynchronous nature of settlement triggered by keepers, the settlement price might deviate, especially during volatile market conditions. This increases the likelihood that an order, that was not liquidatable at creation, becomes liquidatable at settlement.

The market order keeper function that fills a market order is shown below:

https://github.com/Cyfrin/2024-07-zaros/blob/69ccf428b745058bea08804b3f3d961d31406ba8/src/external/chainlink/keepers/market-order/MarketOrderKeeper.sol#L169

function performUpkeep(bytes calldata performData) external onlyForwarder {
(bytes memory signedReport, bytes memory extraData) = abi.decode(performData, (bytes, bytes));
uint128 tradingAccountId = abi.decode(extraData, (uint128));
MarketOrderKeeperStorage storage self = _getMarketOrderKeeperStorage();
(IPerpsEngine perpsEngine, uint128 marketId) = (self.perpsEngine, self.marketId);
@> perpsEngine.fillMarketOrder(tradingAccountId, marketId, signedReport);
}

There are no checks here to verify whether the position being filled is liquidatable. The fillMarketOrder function, triggered by this process, also does not check whether the filled position will result in the user being liquidatable.

Proof Of Concept

Add the following code to liquidateAccounts.t.sol

import { OrderBranch } from "@zaros/perpetuals/branches/OrderBranch.sol";
function testGivenMarketOrderIsCreated_whenFillingOrder_shouldNotAcceptOrderIfItsLiquitadable() public {
// pre conditions -
// 1. create an account and deposit margin.
// 2. create a market order and let it on pending state.
uint256 marginValueUsdc = 100_000e6;
int128 userPositionSizeDelta = 10e18;
deal({ token: address(usdc), to: users.naruto.account, give: marginValueUsdc });
changePrank({ msgSender: users.naruto.account });
uint128 tradingAccountId = createAccountAndDeposit(marginValueUsdc, address(usdc));
bytes memory mockSignedReport = _creteMarketOrder(
BTC_USD_MARKET_ID, BTC_USD_STREAM_ID, MOCK_BTC_USD_PRICE, tradingAccountId, userPositionSizeDelta
);
// price movement - Asset price drops suddenly and position becomes liquidatable.
updateMockPriceFeed(BTC_USD_MARKET_ID, MOCK_BTC_USD_PRICE/2);
// action - fill the market order.
// this should revert as the position is now liquidatable, but it will include the order in the active market orders.
address marketOrderKeeper = marketOrderKeepers[BTC_USD_MARKET_ID];
changePrank({ msgSender: marketOrderKeeper });
perpsEngine.fillMarketOrder(tradingAccountId, BTC_USD_MARKET_ID, mockSignedReport);
// emit a {LogLiquidateAccount} event, as account is liquidated.
vm.expectEmit({
checkTopic1: true,
checkTopic2: true,
checkTopic3: false,
checkData: false,
emitter: address(perpsEngine)
});
emit LiquidationBranch.LogLiquidateAccount({
keeper: liquidationKeeper,
tradingAccountId: tradingAccountId,
amountOfOpenPositions: 0,
requiredMaintenanceMarginUsd: 0,
marginBalanceUsd: 0,
liquidatedCollateralUsd: 0,
liquidationFeeUsd: 0
});
// a bot instananeously liquidates the position.
_liquidateOneAccount(tradingAccountId);
}
function _creteMarketOrder(
uint128 marketId,
bytes32 streamId,
uint256 mockUsdPrice,
uint128 tradingAccountId,
int128 sizeDelta
) internal returns (bytes memory mockSignedReport) {
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: tradingAccountId,
marketId: marketId,
sizeDelta: sizeDelta
})
);
mockSignedReport = getMockedSignedReport(streamId, mockUsdPrice);
}
function _liquidateOneAccount(uint128 tradingAccountId) internal {
changePrank({ msgSender: liquidationKeeper });
skip(1 hours);
uint128[] memory liquidableAccountIds = new uint128[](1);
liquidableAccountIds[0] = tradingAccountId;
perpsEngine.liquidateAccounts(liquidableAccountIds);
}

run: forge test --match-test testGivenMarketOrderIsCreated_whenFillingOrder_shouldNotAcceptOrderIfItsLiquitadable

Output:

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.85ms (1.01ms CPU time)

The test shows the following:

  1. Bob creates an account, deposits collateral, and places a market order.

  2. The collateral price suddenly drops, making Bob's position liquidatable.

  3. The keeper calls fillMarketOrder, and the order is successfully filled.

  4. A bot immediately liquidates the position.

  5. The call doesn't revert.

Impact

Loss of Funds: Not verifying if a position is liquidatable before filling it will cause the user to incur an instant loss.

Tools Used

Manual Review & Foundry

Recommendations

Immediately after the order is filled at the end of the _fillOrder function and before the emit statement, you can perform the isLiquidatable check as shown below. This will cause the fill order to fail if the position is liquidatable.

https://github.com/Cyfrin/2024-07-zaros/blob/69ccf428b745058bea08804b3f3d961d31406ba8/src/perpetuals/branches/SettlementBranch.sol#L505

+ {
+ // // get account's required maintenance margin & unrealized PNL
+ (, UD60x18 requiredMaintenanceMarginUsdX18, SD59x18 accountTotalUnrealizedPnlUsdX18) =
+ tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
+
+ // use unrealized PNL to calculate & output account's margin balance
+ SD59x18 marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18);
+
+ // prevent liquidatable accounts from trading
+ if (TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, marginBalanceUsdX18)) {
+ revert Errors.AccountIsLiquidatable(tradingAccountId);
+ }
+ }
+
emit LogFillOrder(
msg.sender,
tradingAccountId,

Run the PoC again: Result ✅

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 16.90ms (2.29ms CPU time)
[FAIL. Reason: AccountIsLiquidatable(1)]
Updates

Lead Judging Commences

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

`isLiquidatable` check missing in `_fillOrder()`

Appeal created

cryptomoon Judge
about 1 year ago
holydevoti0n Submitter
about 1 year ago
cryptomoon Judge
about 1 year ago
draiakoo Auditor
about 1 year ago
cryptomoon Judge
about 1 year ago
zzebra83 Submitter
about 1 year ago
cryptomoon Judge
about 1 year ago
zzebra83 Submitter
about 1 year ago
cryptomoon Judge
about 1 year ago
zzebra83 Submitter
about 1 year ago
cryptomoon Judge
about 1 year ago
zzebra83 Submitter
about 1 year ago
cryptomoon Judge
about 1 year ago
inallhonesty Lead Judge
12 months ago
inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Validated
Assigned finding tags:

`isLiquidatable` check missing in `_fillOrder()`

Support

FAQs

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