Summary
When trading accounts are liquidated for a particular perp market the self.openInterest and self.skew are set to zero even though there are still accounts with active postions.
Root Cause
https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/branches/LiquidationBranch.sol#L209
function liquidateAccounts(uint128[] calldata accountsIds) external {
if (accountsIds.length == 0) return;
GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
if (!globalConfiguration.isLiquidatorEnabled[msg.sender]) {
revert Errors.LiquidatorNotRegistered(msg.sender);
}
LiquidationContext memory ctx;
ctx.liquidationFeeUsdX18 = ud60x18(globalConfiguration.liquidationFeeUsdX18);
for (uint256 i; i < accountsIds.length; i++) {
ctx.tradingAccountId = accountsIds[i];
if (ctx.tradingAccountId == 0) continue;
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(ctx.tradingAccountId);
(, UD60x18 requiredMaintenanceMarginUsdX18, SD59x18 accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
ctx.marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18);
if (!TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, ctx.marginBalanceUsdX18)) {
continue;
}
ctx.liquidatedCollateralUsdX18 = tradingAccount.deductAccountMargin({
feeRecipients: FeeRecipients.Data({
marginCollateralRecipient: globalConfiguration.marginCollateralRecipient,
orderFeeRecipient: address(0),
settlementFeeRecipient: globalConfiguration.liquidationFeeRecipient
}),
pnlUsdX18: requiredMaintenanceMarginUsdX18,
orderFeeUsdX18: UD60x18_ZERO,
settlementFeeUsdX18: ctx.liquidationFeeUsdX18
});
MarketOrder.load(ctx.tradingAccountId).clear();
ctx.activeMarketsIds = tradingAccount.activeMarketsIds.values();
for (uint256 j; j < ctx.activeMarketsIds.length; j++) {
ctx.marketId = ctx.activeMarketsIds[j].toUint128();
PerpMarket.Data storage perpMarket = PerpMarket.load(ctx.marketId);
Position.Data storage position = Position.load(ctx.tradingAccountId, ctx.marketId);
ctx.oldPositionSizeX18 = sd59x18(position.size);
ctx.liquidationSizeX18 = -ctx.oldPositionSizeX18;
ctx.markPriceX18 = perpMarket.getMarkPrice(ctx.liquidationSizeX18, perpMarket.getIndexPrice());
ctx.fundingRateX18 = perpMarket.getCurrentFundingRate();
ctx.fundingFeePerUnitX18 = perpMarket.getNextFundingFeePerUnit(ctx.fundingRateX18, ctx.markPriceX18);
perpMarket.updateFunding(ctx.fundingRateX18, ctx.fundingFeePerUnitX18);
position.clear();
tradingAccount.updateActiveMarkets(ctx.marketId, ctx.oldPositionSizeX18, SD59x18_ZERO);
perpMarket.updateOpenInterest(ctx.newOpenInterestX18, ctx.newSkewX18);
}
}
}
perpMarket.updateOpenInterest(ctx.newOpenInterestX18, ctx.newSkewX18);
Vulnerability details
Protocol wishes to avoid liquidation DoS in case some cap checks would fail. It is understandable, however function responsible for those checks is also responsible for the calculation of newOpenInterestX18 and newSkewX18. In liquidateAccounts function ctx.newOpenInterestX18 and ctx.newSkewX18 are only initialized to 0. Their value is never modified thus these zeros will become new self.openInterest and new self.skew.
Proof of Concept
Add this test to liquidateAccounts.t.sol file. Run it with forge test --mt "testFuzz_OpenInterestAndSkew".
function testFuzz_OpenInterestAndSkew(
uint256 marketId,
uint256 secondMarketId,
bool isLong,
uint256 amountOfTradingAccounts,
uint256 timeDelta
)
external
givenTheSenderIsARegisteredLiquidator
whenTheAccountsIdsArrayIsNotEmpty
givenAllAccountsExist
{
TestFuzz_GivenThereAreLiquidatableAccountsInTheArray_Context memory ctx;
ctx.fuzzMarketConfig = getFuzzMarketConfig(marketId);
ctx.secondMarketConfig = getFuzzMarketConfig(secondMarketId);
vm.assume(ctx.fuzzMarketConfig.marketId != ctx.secondMarketConfig.marketId);
amountOfTradingAccounts = bound({ x: amountOfTradingAccounts, min: 1, max: 10 });
timeDelta = bound({ x: timeDelta, min: 1 seconds, max: 1 days });
ctx.marginValueUsd = 10_000e18 / amountOfTradingAccounts;
ctx.initialMarginRate = ctx.fuzzMarketConfig.imr;
deal({ token: address(usdz), to: users.naruto.account, give: ctx.marginValueUsd });
ctx.accountsIds = new uint128[](amountOfTradingAccounts + 2);
ctx.accountMarginValueUsd = ctx.marginValueUsd / (amountOfTradingAccounts + 1);
for (uint256 i; i < amountOfTradingAccounts; i++) {
ctx.tradingAccountId = createAccountAndDeposit(ctx.accountMarginValueUsd, address(usdz));
openPosition(
ctx.fuzzMarketConfig,
ctx.tradingAccountId,
ctx.initialMarginRate,
ctx.accountMarginValueUsd / 2,
isLong
);
openPosition(
ctx.secondMarketConfig,
ctx.tradingAccountId,
ctx.secondMarketConfig.imr,
ctx.accountMarginValueUsd / 2,
isLong
);
ctx.accountsIds[i] = ctx.tradingAccountId;
deal({ token: address(usdz), to: users.naruto.account, give: ctx.marginValueUsd });
}
setAccountsAsLiquidatable(ctx.fuzzMarketConfig, isLong);
setAccountsAsLiquidatable(ctx.secondMarketConfig, isLong);
ctx.nonLiquidatableTradingAccountId = createAccountAndDeposit(ctx.accountMarginValueUsd, address(usdz));
openPosition(
ctx.fuzzMarketConfig,
ctx.nonLiquidatableTradingAccountId,
ctx.initialMarginRate,
ctx.accountMarginValueUsd / 4,
isLong
);
openPosition(
ctx.secondMarketConfig,
ctx.nonLiquidatableTradingAccountId,
ctx.secondMarketConfig.imr,
ctx.accountMarginValueUsd / 4,
isLong
);
ctx.accountsIds[amountOfTradingAccounts] = ctx.nonLiquidatableTradingAccountId;
changePrank({ msgSender: liquidationKeeper });
skip(timeDelta);
perpsEngine.liquidateAccounts(ctx.accountsIds);
(,, ctx.openInterestX18) = perpsEngine.getOpenInterest(ctx.fuzzMarketConfig.marketId);
ctx.expectedOpenInterest = sd59x18(
PositionHarness(address(perpsEngine)).exposed_Position_load(
ctx.nonLiquidatableTradingAccountId, ctx.fuzzMarketConfig.marketId
).size
).abs().intoUD60x18().intoUint256();
assertNotEq(ctx.expectedOpenInterest, 0);
assertEq(ctx.openInterestX18.intoUint256(), 0);
ctx.skewX18 = perpsEngine.getSkew(ctx.fuzzMarketConfig.marketId);
ctx.expectedSkew = PositionHarness(address(perpsEngine)).exposed_Position_load(
ctx.nonLiquidatableTradingAccountId, ctx.fuzzMarketConfig.marketId
).size;
assertNotEq(ctx.expectedSkew, 0);
assertEq(ctx.skewX18.intoUint256(), 0);
}
We can see that the open interest and skew are equal zero even though there are active positions in market.
Impact
After liquidation the open interest and the skew are not calculated. As a result the protocol will store incorrect values of open interest and skew. Wrong skew will affect funding fees being paid to traders. A profitable trader can lose part of their profits or some positions could become liquidable even though they were above maintenance margin threshold. This issue will generate loss for users.
Recommended Mitigation Steps
Calculate newOpenInterest and newSkew in liquidateAccounts function. To do that you can use code from checkOpenInterestLimits. Do not include checks.
function checkOpenInterestLimits(
Data storage self,
SD59x18 sizeDelta,
SD59x18 oldPositionSize,
SD59x18 newPositionSize
)
internal
view
returns (UD60x18 newOpenInterest, SD59x18 newSkew)
{
// load max & current open interest for this perp market uint128 -> UD60x18
UD60x18 maxOpenInterest = ud60x18(self.configuration.maxOpenInterest);
UD60x18 currentOpenInterest = ud60x18(self.openInterest);
// calculate new open interest which would result from proposed trade
// by subtracting old position size then adding new position size to
// current open interest
newOpenInterest =
currentOpenInterest.sub(oldPositionSize.abs().intoUD60x18()).add(newPositionSize.abs().intoUD60x18());
- // if new open interest would be greater than this market's max open interest,
- // we still want to allow trades as long as they decrease the open interest. This
- // allows traders to reduce/close their positions in markets where protocol admins
- // have reduced the max open interest to reduce the protocol's exposure to a given
- // perp market
- if (newOpenInterest.gt(maxOpenInterest)) {
- // is the proposed trade reducing open interest?
- bool isReducingOpenInterest = currentOpenInterest.gt(newOpenInterest);
- // revert if the proposed trade isn't reducing open interest
- if (!isReducingOpenInterest) {
- revert Errors.ExceedsOpenInterestLimit(
- self.id, maxOpenInterest.intoUint256(), newOpenInterest.intoUint256()
- );
- }
- }
// load max & current skew for this perp market uint128 -> UD60x18 -> SD59x18
SD59x18 maxSkew = ud60x18(self.configuration.maxSkew).intoSD59x18();
// int128 -> SD59x18
SD59x18 currentSkew = sd59x18(self.skew);
// calculate new skew
newSkew = currentSkew.add(sizeDelta);
- // similar logic to the open interest check; if the new skew is greater than
- // the max, we still want to allow trades as long as they decrease the skew
- if (newSkew.abs().gt(maxSkew)) {
- bool isReducingSkew = currentSkew.abs().gt(newSkew.abs());
- if (!isReducingSkew) {
- revert Errors.ExceedsSkewLimit(self.id, maxSkew.intoUint256(), newSkew.intoInt256());
- }
- }
}