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

`OrderBranch::simulateTrade()` performed a check using the wrong variable, causing the check to fail.

Summary

OrderBranch::simulateTrade() performed a check using the wrong variable, causing the check to fail.

Vulnerability Details

  1. The marginBalanceUsdX18 value returned according to the comment content. The account status before the simulated transaction should be used for calculation.

  2. The position maintenance margin after the simulated transaction should be used as a parameter to call TradingAccount.isLiquidatable(ctx.previousRequiredMaintenanceMarginUsdX18, marginBalanceUsdX18).

  3. When calling TradingAccount.isLiquidatable(ctx.previousRequiredMaintenanceMarginUsdX18, marginBalanceUsdX18) with marginBalanceUsdX18 as a parameter, orderFeeUsdX18 and settlementFeeUsdX18 should be calculated in advance, otherwise the marginBalanceUsdX18 of the simulated transaction will be greater than the value of the actual transaction.

/// @notice Simulates the settlement costs and validity of a given order.
/// @dev Reverts if there's not enough margin to cover the trade.
/// @param tradingAccountId The trading account id.
/// @param marketId The perp market id.
/// @param settlementConfigurationId The perp market settlement configuration id.
/// @param sizeDelta The size delta of the order.
@> /// @return marginBalanceUsdX18 The given account's current margin balance.
/// @return requiredInitialMarginUsdX18 The required initial margin to settle the given trade.
/// @return requiredMaintenanceMarginUsdX18 The required maintenance margin to settle the given trade.
/// @return orderFeeUsdX18 The order fee in USD.
/// @return settlementFeeUsdX18 The settlement fee in USD.
/// @return fillPriceX18 The fill price quote.
function simulateTrade(
uint128 tradingAccountId,
uint128 marketId,
uint128 settlementConfigurationId,
int128 sizeDelta
)
public
view
returns (
SD59x18 marginBalanceUsdX18,
UD60x18 requiredInitialMarginUsdX18,
UD60x18 requiredMaintenanceMarginUsdX18,
UD60x18 orderFeeUsdX18,
UD60x18 settlementFeeUsdX18,
UD60x18 fillPriceX18
)
{
// load existing trading account; reverts for non-existent account
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(tradingAccountId);
// fetch storage slot for perp market
PerpMarket.Data storage perpMarket = PerpMarket.load(marketId);
// fetch storage slot for perp market's settlement config
SettlementConfiguration.Data storage settlementConfiguration =
SettlementConfiguration.load(marketId, settlementConfigurationId);
// working data
SimulateTradeContext memory ctx;
// int128 -> SD59x18
ctx.sizeDeltaX18 = sd59x18(sizeDelta);
// calculate & output execution price
fillPriceX18 = perpMarket.getMarkPrice(ctx.sizeDeltaX18, perpMarket.getIndexPrice());
// calculate & output order fee
@> orderFeeUsdX18 = perpMarket.getOrderFeeUsd(ctx.sizeDeltaX18, fillPriceX18);
// output settlement fee uint80 -> uint256 -> UD60x18
@> settlementFeeUsdX18 = ud60x18(uint256(settlementConfiguration.fee));
// calculate & output required initial & maintenance margin for the simulated trade
// and account's unrealized PNL
(requiredInitialMarginUsdX18, requiredMaintenanceMarginUsdX18, ctx.accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(marketId, ctx.sizeDeltaX18);
// use unrealized PNL to calculate & output account's margin balance
@> marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(ctx.accountTotalUnrealizedPnlUsdX18);
{
// get account's current required margin maintenance (before this trade)
(, ctx.previousRequiredMaintenanceMarginUsdX18,) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
// prevent liquidatable accounts from trading
@> if (TradingAccount.isLiquidatable(ctx.previousRequiredMaintenanceMarginUsdX18, marginBalanceUsdX18)) {
revert Errors.AccountIsLiquidatable(tradingAccountId);
}
}
{
// fetch storage slot for account's potential existing position in this market
Position.Data storage position = Position.load(tradingAccountId, marketId);
// calculate and store new position size in working data
ctx.newPositionSizeX18 = sd59x18(position.size).add(ctx.sizeDeltaX18);
// revert if
// 1) this trade doesn't close an existing open position AND
// 2) abs(newPositionSize) is smaller than minimum position size
// this enforces a minimum position size both for new trades but
// also for existing trades which have their position size reduced
if (
!ctx.newPositionSizeX18.isZero()
&& ctx.newPositionSizeX18.abs().lt(sd59x18(int256(uint256(perpMarket.configuration.minTradeSizeX18))))
) {
revert Errors.NewPositionSizeTooSmall();
}
}
}

Because this method is used in OrderBranch::createMarketOrder() to check funds.
We can assume a scenario:

  1. The user creates a trading order for the first time, so the account is currently in the state: ctx.previousRequiredMaintenanceMarginUsdX18 == 0.

  2. If you want to pass the check in the prevent liquidatable accounts from trading part of the code, you only need to ensure that marginBalanceUsdX18 > 0.

In summary, in the current case, the sizeDelta parameter when calling OrderBranch::simulateTrade() can be infinite.

We should consider using the requiredMaintenanceMarginUsdX18 after the simulated transaction and the marginBalanceUsdX18 before the transaction as parameters to execute the judgment in the if (TradingAccount.isLiquidatable(ctx.previousRequiredMaintenanceMarginUsdX18, marginBalanceUsdX18)){} part. And use the marginBalanceUsdX18 after deducting the fee

Poc

In order to facilitate viewing the data in the function, we add the test code in OrderBranch::simulateTrade() as follows:

+ import "forge-std/console.sol";
(requiredInitialMarginUsdX18, requiredMaintenanceMarginUsdX18, ctx.accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(marketId, ctx.sizeDeltaX18);
+ console.log("OrderBranch::simulateTrade::requiredInitialMarginUsdX18:",unwrap(requiredInitialMarginUsdX18));
+ console.log("OrderBranch::simulateTrade::requiredMaintenanceMarginUsdX18:",unwrap(requiredMaintenanceMarginUsdX18));
+ console.log("OrderBranch::simulateTrade::ctx.accountTotalUnrealizedPnlUsdX18:",unwrap(ctx.accountTotalUnrealizedPnlUsdX18));
// use unrealized PNL to calculate & output account's margin balance
marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(ctx.accountTotalUnrealizedPnlUsdX18);
{
// get account's current required margin maintenance (before this trade)
(, ctx.previousRequiredMaintenanceMarginUsdX18,) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
+ console.log("OrderBranch::simulateTrade::ctx.previousRequiredMaintenanceMarginUsdX18:",unwrap(ctx.previousRequiredMaintenanceMarginUsdX18));
// prevent liquidatable accounts from trading
if (TradingAccount.isLiquidatable(ctx.previousRequiredMaintenanceMarginUsdX18, marginBalanceUsdX18)) {
revert Errors.AccountIsLiquidatable(tradingAccountId);
}
}

Please add the test code to test/integration/perpetuals/order-branch/simulateTrade/simulateTrade.t.sol and execute

function test_Poc_OrderBranch_simulateTrade()
external
givenTheAccountIdExists
givenThePerpMarketIdExists
whenTheSizeDeltaIsNotZero
whenThereIsSufficientLiquidity
whenTheTradingAccountIsNotLiquidatable
{
uint256 marketId = 0;
uint256 initialMarginRate = 1e18 ;
uint256 marginValueUsd = 100e6;
bool isLong = true;
// it should simulate the trade correctly
MarketConfig memory fuzzMarketConfig = getFuzzMarketConfig(marketId);
console.log("marketId:",fuzzMarketConfig.marketId); // 1
// give user usdc
deal({ token: address(usdc), to: users.naruto.account, give: marginValueUsd });
// create trading account
uint128 tradingAccountId = createAccountAndDeposit(marginValueUsd, address(usdc));
// Opening a Position
openPosition(fuzzMarketConfig, tradingAccountId, initialMarginRate, marginValueUsd, isLong);
setAccountsAsLiquidatable(fuzzMarketConfig, isLong);
// To simplify the test code, we use the value returned by fuzzOrderSizeDelta() in the current case: 1000000000000000
int128 sizeDelta = fuzzOrderSizeDelta(
FuzzOrderSizeDeltaParams({
tradingAccountId: tradingAccountId,
marketId: fuzzMarketConfig.marketId,
settlementConfigurationId: SettlementConfiguration.MARKET_ORDER_CONFIGURATION_ID,
initialMarginRate: ud60x18(initialMarginRate),
marginValueUsd: ud60x18(marginValueUsd),
maxSkew: ud60x18(fuzzMarketConfig.maxSkew),
minTradeSize: ud60x18(fuzzMarketConfig.minTradeSize),
price: ud60x18(fuzzMarketConfig.mockUsdPrice),
isLong: isLong,
shouldDiscountFees: true
})
);
{
console.log("--------------------------simulateTrade()------------------");
console2.log("sizeDelta:",sizeDelta);
// simulateTrade() Return Value
( SD59x18 simulateTrade_marginBalanceUsdX18,
UD60x18 simulateTrade_requiredInitialMarginUsdX18,
UD60x18 simulateTrade_requiredMaintenanceMarginUsdX18,
UD60x18 simulateTrade_orderFeeUsdX18,
UD60x18 simulateTrade_settlementFeeUsdX18,
UD60x18 simulateTrade_fillPriceX18) = perpsEngine.simulateTrade(
tradingAccountId,
fuzzMarketConfig.marketId,
SettlementConfiguration.MARKET_ORDER_CONFIGURATION_ID,
sizeDelta * 100000000// Because of the incorrect check that `sizeDelta` can be any value
);
console.log("----------- simulateTrade() Return Value ------------------");
console.log("simulateTrade_marginBalanceUsdX18:",unwrap(simulateTrade_marginBalanceUsdX18)); // The given account's current margin balance.
console.log("simulateTrade_requiredInitialMarginUsdX18:",unwrap(simulateTrade_requiredInitialMarginUsdX18)); // The required initial margin to settle the given trade.
console.log("simulateTrade_requiredMaintenanceMarginUsdX18:",unwrap(simulateTrade_requiredMaintenanceMarginUsdX18)); // The required maintenance margin to settle the given trade.
console.log("simulateTrade_orderFeeUsdX18:",unwrap(simulateTrade_orderFeeUsdX18)); // The order fee in USD.
console.log("simulateTrade_settlementFeeUsdX18:",unwrap(simulateTrade_settlementFeeUsdX18)); // The settlement fee in USD.
console.log("simulateTrade_fillPriceX18:",unwrap(simulateTrade_fillPriceX18)); // The fill price quote.
}
}
// Ran 1 test for test/integration/perpetuals/order-branch/simulateTrade/simulateTrade.t.sol:SimulateTradeIntegrationTest
// [PASS] test_Poc_OrderBranch_simulateTrade() (gas: 1494107)

output:

@> OrderBranch::simulateTrade::marginBalanceUsdX18: 97320000009906000000
@> OrderBranch::simulateTrade::ctx.previousRequiredMaintenanceMarginUsdX18: 497000000024850000
--------------------------simulateTrade()------------------
sizeDelta: 1000000000000000
@> OrderBranch::simulateTrade::marginBalanceUsdX18: 97817000004936000000
@> OrderBranch::simulateTrade::ctx.previousRequiredMaintenanceMarginUsdX18: 497000000024850000
----------- simulateTrade() Return Value ------------------
@> simulateTrade_marginBalanceUsdX18: 97817000004936000000
simulateTrade_requiredInitialMarginUsdX18: 99897001008910000099400000
simulateTrade_requiredMaintenanceMarginUsdX18: 49948500504455000049700000
simulateTrade_orderFeeUsdX18: 7991760000795200000000000
simulateTrade_settlementFeeUsdX18: 2000000000000000000
simulateTrade_fillPriceX18: 99897000009940000000000

Code Snippet

https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/branches/OrderBranch.sol#L79-L157

Impact

OrderBranch::simulateTrade() performed a check using the wrong variable, causing the check to fail.

Tools Used

Manual Review

Recommendations

Consider using the required maintenance margin after the trade and the margin balance before the trade for comparison

  1. According to the annotation requirements, return the current margin balance of the account

  2. The unrealized profit and loss of the account after the transaction will change slightly due to the slope. If the unrealized profit and loss becomes smaller as in the above test, the calculated account margin balance will be larger than the actual one. So the following uses the current margin balance and the required maintenance margin after the transaction for comparison

Note: The original function did not check whether the account is currently in a clear state, so the following modification content did not include related inspections. If necessary, please check at the beginning of the function.

For example:

(requiredInitialMarginUsdX18, requiredMaintenanceMarginUsdX18, ctx.accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(marketId, ctx.sizeDeltaX18);
// use unrealized PNL to calculate & output account's margin balance
- marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(ctx.accountTotalUnrealizedPnlUsdX18);
{
// get account's current required margin maintenance (before this trade)
// Cache the unrealized profit and loss of the account before the transaction, which is used to calculate the current margin balance of the account
+ SD59x18 currentAccountTotalUnrealizedPnlUsdX18;
- (, ctx.previousRequiredMaintenanceMarginUsdX18,) =
+ (, ctx.previousRequiredMaintenanceMarginUsdX18, currentAccountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
// Calculate the margin balance before trading
+ marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(currentAccountTotalUnrealizedPnlUsdX18);
// prevent liquidatable accounts from trading
- if (TradingAccount.isLiquidatable(ctx.previousRequiredMaintenanceMarginUsdX18, marginBalanceUsdX18)) {
// Perform checks after deducting relevant fees from the margin balance
+ if (TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, marginBalanceUsdX18 - sd59x18(int256(unwrap(orderFeeUsdX18))) - sd59x18(int256(unwrap(settlementFeeUsdX18))))) {
revert Errors.AccountIsLiquidatable(tradingAccountId);
}
}

test again, it's work
We can see in the test that simulateTrade() works as expected and the calculated post-trade margin balance is the same as the margin balance after the trade is completed.

OrderBranch::simulateTrade::requiredInitialMarginUsdX18: 1000000000050000000
OrderBranch::simulateTrade::requiredMaintenanceMarginUsdX18: 500000000025000000
OrderBranch::simulateTrade::ctx.accountTotalUnrealizedPnlUsdX18: 0
OrderBranch::simulateTrade::marginBalanceUsdX18: 50000000000000000000
OrderBranch::simulateTrade::marginBalanceUsdX18 after deducting fees: 47919999999996000000
OrderBranch::simulateTrade::requiredInitialMarginUsdX18: 1000000000050000000
OrderBranch::simulateTrade::requiredMaintenanceMarginUsdX18: 500000000025000000
OrderBranch::simulateTrade::ctx.accountTotalUnrealizedPnlUsdX18: 0
OrderBranch::simulateTrade::marginBalanceUsdX18: 50000000000000000000
# We can see in the test that `simulateTrade()` works as expected and the calculated post-trade margin balance is the same as the margin balance after the trade is completed.
@> OrderBranch::simulateTrade::marginBalanceUsdX18 after deducting fees: 47919999999996000000 # The cleaned `marginBalanceUsdX18` is the same as in the position status below # 1
changePrank is deprecated. Please use vm.startPrank instead.
changePrank is deprecated. Please use vm.startPrank instead.
--------- get Account Margin Breakdown before setAccountsAsLiquidatable() ----------
@> getmarginBalanceUsdX18 47919999999996000000 # 1
getinitialMarginUsdX18 1000000000050000000
getmaintenanceMarginUsdX18 500000000025000000
getavailableMarginUsdX18 46919999999946000000
--------- get Account Margin Breakdown after setAccountsAsLiquidatable() ----------
getmarginBalanceUsdX18 47319999999966000000 # 47319999999966000000 == (47919999999996000000 + -600000000030000000)
getinitialMarginUsdX18 994000000049700000
getmaintenanceMarginUsdX18 497000000024850000
getavailableMarginUsdX18 46325999999916300000
---------- Position Status after setAccountsAsLiquidatable() ----------
positionState.sizeX18: 1000000000000000
positionState.notionalValueX18: 99400000004970000000
positionState.initialMarginUsdX18: 994000000049700000
positionState.maintenanceMarginUsdX18: 497000000024850000
positionState.entryPriceX18: 100000000005000000000000
positionState.accruedFundingUsdX18: 0
positionState.unrealizedPnlUsdX18: -600000000030000000 # 2
OrderBranch::simulateTrade::requiredInitialMarginUsdX18: 1988000000298200000
OrderBranch::simulateTrade::requiredMaintenanceMarginUsdX18: 994000000149100000
OrderBranch::simulateTrade::ctx.accountTotalUnrealizedPnlUsdX18: -599999990090000000
OrderBranch::simulateTrade::marginBalanceUsdX18: 47319999999966000000
OrderBranch::simulateTrade::marginBalanceUsdX18 after deducting fees: 45240479999954072000
--------------------------simulateTrade()------------------
# Because the test uses an exaggerated sizeDelta value (sizeDelta * 100000000), the display is as follows
sizeDelta: 1000000000000000
OrderBranch::simulateTrade::requiredInitialMarginUsdX18: 99897001008910000099400000
OrderBranch::simulateTrade::requiredMaintenanceMarginUsdX18: 49948500504455000049700000
OrderBranch::simulateTrade::ctx.accountTotalUnrealizedPnlUsdX18: -102999995060000000
OrderBranch::simulateTrade::marginBalanceUsdX18: 47319999999966000000
OrderBranch::simulateTrade::marginBalanceUsdX18 after deducting fees: -7991714680795200034000000
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 25.50ms (6.95ms CPU time)
Ran 1 test suite in 326.85ms (25.50ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/integration/perpetuals/order-branch/simulateTrade/simulateTrade.t.sol:SimulateTradeIntegrationTest
# Recover as expected
@> [FAIL. Reason: AccountIsLiquidatable(1)] test_Poc_OrderBranch_simulateTrade() (gas: 1837976)
Updates

Lead Judging Commences

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

The protocol compares the margin balance after the trade with current required maintenance margin to determine if the trading account is currently liquidatable

Support

FAQs

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