Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: medium
Invalid

Users Cannot Be Liquidated When Market is Paused on `marketMakingEngine`

Summary

If a market is paused on marketMakingEngine, liquidation attempts for accounts within that market will revert, but if it is paused on PrepEngine it will not revert.

Vulnerability Details

Consider a scenario where a user opens a leveraged positions in an active market. Their account becomes liquidatable due to insufficient margin. The market is then paused or disabled by calling marketMakingEngine::pauseMarket function.

/src/market-making/branches/MarketMakingEngineConfigurationBranch.sol:578
578: function pauseMarket(uint128 marketId) external onlyOwner returns (bool success) {
579: success = LiveMarkets.load().removeMarket(marketId);
580: if (success) emit LogMarketPaused(marketId);
581: }

The liquidationKeeper calls perpsEngine::liquidateAccounts(), which internally calls CreditDelegationBranch::depositCreditForMarket.

/src/perpetuals/leaves/TradingAccount.sol:621
621: marketMakingEngine.depositCreditForMarket(
622: uint128(params.marketIds[j]),
623: collateralType,
624: marginCollateralConfiguration.convertUd60x18ToTokenAmount(collateralAmountX18)
625: );

depositCreditForMarket function checks if the market is live ? by calling Market::loadLive.

/src/market-making/branches/CreditDelegationBranch.sol:198
198: Market.Data storage market = Market.loadLive(marketId);

The transaction reverts instead of allowing liquidation.

/src/market-making/leaves/Market.sol:120
120: function loadLive(uint128 marketId) internal view returns (Data storage market) {
121: market = loadExisting(marketId);
122:
123: if (!LiveMarkets.load().containsMarket(marketId)) {
124: revert Errors.MarketIsDisabled(marketId);
125: }
126: }

Consider below proof of code:

POC

/2025-01-zaros-part-2/test/integration/perpetuals/liquidation-branch/liquidateAccounts/liquidateAccounts.t.sol:100
100: function test_revert_liquidation_call() // @audit POC
101: external
102: givenTheSenderIsARegisteredLiquidator
103: whenTheAccountsIdsArrayIsNotEmpty
104: givenAllAccountsExist
105: {
106: // fuzz args=[31177684502158619379073888611697022634825149689041911218646198327836032260383, 14618 , false, 2992, 19068]
107: uint256 marketId = 3.117e76;
108: uint256 secondMarketId=14618;
109: bool isLong=false;
110: uint256 amountOfTradingAccounts =2992;
111: uint256 timeDelta = 19068;
112: TestFuzz_GivenThereAreLiquidatableAccountsInTheArray_Context memory ctx;
113:
114: ctx.fuzzMarketConfig = getFuzzMarketConfig(marketId);
115: ctx.secondMarketConfig = getFuzzMarketConfig(secondMarketId);
116:
117: vm.assume(ctx.fuzzMarketConfig.marketId != ctx.secondMarketConfig.marketId);
118:
119: amountOfTradingAccounts = bound({ x: amountOfTradingAccounts, min: 1, max: 10 });
120: timeDelta = bound({ x: timeDelta, min: 1 seconds, max: 1 days });
121:
122: ctx.marginValueUsd = 10_000e18 / amountOfTradingAccounts;
123: ctx.initialMarginRate = ctx.fuzzMarketConfig.imr;
124:
125: deal({ token: address(usdToken), to: users.naruto.account, give: ctx.marginValueUsd });
126:
127: // last account id == 0
128: ctx.accountsIds = new uint128[](amountOfTradingAccounts + 2);
129: ctx.accountsUnrealizedPnl = new SD59x18[](amountOfTradingAccounts + 2);
130: ctx.accountsMarginBalanceInitial = new SD59x18[](amountOfTradingAccounts + 2);
131:
132: ctx.accountMarginValueUsd = ctx.marginValueUsd / (amountOfTradingAccounts + 1);
133:
134: for (uint256 i; i < amountOfTradingAccounts; i++) {
135: ctx.tradingAccountId = createAccountAndDeposit(ctx.accountMarginValueUsd, address(usdToken));
136:
137: openPosition(
138: ctx.fuzzMarketConfig,
139: ctx.tradingAccountId,
140: ctx.initialMarginRate,
141: ctx.accountMarginValueUsd / 2,
142: isLong
143: );
144:
145: openPosition(
146: ctx.secondMarketConfig,
147: ctx.tradingAccountId,
148: ctx.secondMarketConfig.imr,
149: ctx.accountMarginValueUsd / 2,
150: isLong
151: );
152:
153: ctx.accountsIds[i] = ctx.tradingAccountId;
154: ctx.accountsMarginBalanceInitial[i] =
155: perpsEngine.exposed_getMarginBalanceUsd(ctx.accountsIds[i], sd59x18(0));
156:
157: deal({ token: address(usdToken), to: users.naruto.account, give: ctx.marginValueUsd });
158: }
159:
160: setAccountsAsLiquidatable(ctx.fuzzMarketConfig, isLong);
161: setAccountsAsLiquidatable(ctx.secondMarketConfig, isLong);
162:
163: ctx.nonLiquidatableTradingAccountId = createAccountAndDeposit(ctx.accountMarginValueUsd, address(usdToken));
164: openPosition(
165: ctx.fuzzMarketConfig,
166: ctx.nonLiquidatableTradingAccountId,
167: ctx.fuzzMarketConfig.imr,
168: ctx.accountMarginValueUsd / 2,
169: isLong
170: );
171:
172: changePrank({ msgSender: liquidationKeeper });
173:
174: skip(timeDelta);
175:
176: for (uint256 i; i < ctx.accountsIds.length; i++) {
177: (,, ctx.accountsUnrealizedPnl[i]) = perpsEngine.exposed_getAccountMarginRequirementUsdAndUnrealizedPnlUsd(
178: ctx.accountsIds[i], 0, sd59x18(0)
179: );
180:
181: if (ctx.accountsIds[i] == ctx.nonLiquidatableTradingAccountId || ctx.accountsIds[i] == 0) {
182: continue;
183: }
184: }
185:
186: ctx.expectedLastFundingRate = perpsEngine.getFundingRate(ctx.fuzzMarketConfig.marketId).intoInt256();
187: ctx.expectedLastFundingTime = block.timestamp;
188:
189: changePrank({ msgSender: users.owner.account });
190: marketMakingEngine.pauseMarket(ctx.fuzzMarketConfig.marketId); // pause/disable the market
191:
192: changePrank({ msgSender: liquidationKeeper });
193: perpsEngine.liquidateAccounts(ctx.accountsIds); // will revert here
194: }

Impact

Unliquidated accounts continue holding bad debt, increasing risk for the protocol. In case market is not Live on MarketMakingEngine .

Tools Used

Manual Review, Unit Testing

Recommendations

Consider allowing liquidation even if the market is paused, ensuring risk is managed.

Updates

Lead Judging Commences

inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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