DeFiFoundry
60,000 USDC
View results
Submission Details
Severity: high
Invalid

Small positions with huge collateral can't be liquidated and will farm USD tokens risk-free

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)
{
// cache colllateral length
uint256 cachedMarginCollateralBalanceLength = self.marginCollateralBalanceX18.length();
// iterate over every collateral account has deposited
for (uint256 i; i < cachedMarginCollateralBalanceLength; i++) {
// read key/value from storage for current iteration
(address collateralType, uint256 balance) = self.marginCollateralBalanceX18.at(i);
// load collateral margin config for this collateral type
MarginCollateralConfiguration.Data storage marginCollateralConfiguration =
MarginCollateralConfiguration.load(collateralType);
// calculate the collateral's "effective" balance as:
// collateral_price * deposited_balance * collateral_loan_to_value_ratio
UD60x18 adjustedBalanceUsdX18 = marginCollateralConfiguration.getPrice().mul(ud60x18(balance)).mul(
ud60x18(marginCollateralConfiguration.loanToValue)
);
// add this account's "effective" collateral balance to cumulative output
marginBalanceUsdX18 = marginBalanceUsdX18.add(adjustedBalanceUsdX18.intoSD59x18());
}
// finally add the unrealized PNL to the cumulative output
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);
// mint settlement tokens credited to trader; tokens are minted to
// address(this) since they have been credited to trader's deposited collateral
//
// NOTE: testnet only - this call will be updated once the Market Making Engine is finalized
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; //ETH/USD market
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());
//TODO can't be liquidated even when price falls from $2000 to $1
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.

Updates

Lead Judging Commences

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

Appeal created

blckhv Submitter
10 months ago
inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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