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

Upgrading the funding velocity has an immediate impact on all existing positions

Summary

The maxFundingVelocity parameter in the protocol governs the rate at which funding costs accrue on open positions. In the protocol implementation, the funding rate is dynamically calculated based on this velocity and the proportional time elapsed since the last funding update.

When maxFundingVelocity is updated via the upgradable mechanism, the new funding velocity cap is applied immediately and affects all open positions, disregarding the time already elapsed under the previous rate.

This immediate application of the new funding velocity cap leads to a sudden change in the opened leveraged positions. Users' expected funding costs could shift significantly without considering the period during which the previous rate was in effect. This behavior could have implications for risk management and position planning for traders.

Vulnerability Details

function getCurrentFundingRate(Data storage self) internal view returns (SD59x18) {
return sd59x18(self.lastFundingRate).add(
getCurrentFundingVelocity(self).mul(getProportionalElapsedSinceLastFunding(self).intoSD59x18())
);
}
function getCurrentFundingVelocity(Data storage self) internal view returns (SD59x18) {
@> SD59x18 maxFundingVelocity = sd59x18(uint256(self.configuration.maxFundingVelocity).toInt256());
SD59x18 skewScale = sd59x18(uint256(self.configuration.skewScale).toInt256());
SD59x18 skew = sd59x18(self.skew);
if (skewScale.isZero()) {
return SD59x18_ZERO;
}
SD59x18 proportionalSkew = skew.div(skewScale);
SD59x18 proportionalSkewBounded = Math.min(Math.max(unary(SD_UNIT), proportionalSkew), SD_UNIT);
@> return proportionalSkewBounded.mul(maxFundingVelocity);
}
function getProportionalElapsedSinceLastFunding(Data storage self) internal view returns (UD60x18) {
@> return ud60x18Convert(block.timestamp - self.lastFundingTime).div(
ud60x18Convert(Constants.PROPORTIONAL_FUNDING_PERIOD)
);
}

The lastFundingTime variable is updated every time a liquidation occurs or a position is opened. While this variable is frequently updated in highly active markets, it can lead to unexpected behavior in less active markets where the funding rate might not reflect the actual accrued costs over time.

This design can cause unexpected and immediate margin depletions, resulting in the liquidation of user positions in extreme cases. In an environment with high leverage, such as 100x, the impact is even more pronounced.

Proof of Concept

Initial Setup:

  • A user opens a long position with 100x leverage.

  • The initial funding rate is set to 0.01% per hour (0.24% per day).

  • Market Movement: Over 8 hours, the asset’s price drops slightly. The user's position remains healthy, although closer to the liquidation threshold.

Protocol Upgrade:

  • The protocol administrators decide to upgrade the funding rate calculation and maximum funding velocity is increased by a factor of 10.

  • Post-Upgrade Effects: The funding rate immediately jumps to a higher value due to the increased maximum funding velocity. The accumulated funding fees over the past 8 hours are recalculated based on the new, higher rate.

  • Unexpected Liquidation: The sudden increase in accumulated funding fees pushes the user's position below the maintenance margin threshold.

  • The position becomes eligible for liquidation, even though the market price movement alone was not sufficient to trigger liquidation.

Proof of Code

Add the following code to a new file in the test suite:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.25;
import { Base_Test } from "test/Base.t.sol";
import { MarketConfiguration } from "@zaros/perpetuals/leaves/MarketConfiguration.sol";
import { PerpMarketHarness } from "test/harnesses/perpetuals/leaves/PerpMarketHarness.sol";
import { PositionHarness } from "test/harnesses/perpetuals/leaves/PositionHarness.sol";
import { OrderBranch } from "@zaros/perpetuals/branches/OrderBranch.sol";
import { MockPriceFeed } from "test/mocks/MockPriceFeed.sol";
import { UD60x18, ud60x18 } from "@prb-math/UD60x18.sol";
import { SD59x18, sd59x18 } from "@prb-math/SD59x18.sol";
import { TradingAccountHarness } from "test/harnesses/perpetuals/leaves/TradingAccountHarness.sol";
import { GlobalConfigurationBranch } from "@zaros/perpetuals/branches/GlobalConfigurationBranch.sol";
contract FundingRateUpgrade_Test is Base_Test {
MarketConfig fuzzMarketConfig;
MockPriceFeed priceFeed;
uint256 initialPrice;
function setUp() public override {
Base_Test.setUp();
changePrank({ msgSender: users.owner.account });
configureSystemParameters();
createPerpMarkets();
fuzzMarketConfig = getFuzzMarketConfig(BTC_USD_MARKET_ID);
// set initial Funding rate (e.g. 0.01% per hour)
PerpMarketHarness perpMarket = PerpMarketHarness(address(perpsEngine));
perpMarket.exposed_updateFunding({
marketId: fuzzMarketConfig.marketId,
fundingRate: sd59x18(2.4e15), // e.g. 0.24% = 0.01% / hour
fundingFeePerUnit: sd59x18(0)
});
// For simplicity let's keep a round number
initialPrice = 10_000e18;
priceFeed = MockPriceFeed(fuzzMarketConfig.priceAdapter);
priceFeed.updateMockPrice(initialPrice);
}
struct TestVariables {
uint128 tradingAccountId;
SD59x18 initialMarginBalance;
UD60x18 initialMarginRequired;
UD60x18 maintenanceMargin;
SD59x18 marginBalanceBeforeUpdate;
SD59x18 marginBalanceAfterUpdate;
SD59x18 fundingRateBeforeUpdate;
SD59x18 fundingRateAfterUpdate;
SD59x18 accruedFundingBeforeUpdate;
SD59x18 accruedFundingAfterUpdate;
}
function testFundingVelocityUpgradeImpactsBalances() public {
TestVariables memory vars;
deal({ token: address(usdc), to: users.naruto.account, give: 1_000_000e6 });
changePrank({ msgSender: users.naruto.account });
// Opening a x 100 leverage position
vars.tradingAccountId = createAccountAndDeposit(10_000e6, address(usdc));
int128 positionSize = 100e18;
perpsEngine.createMarketOrder(
OrderBranch.CreateMarketOrderParams({
tradingAccountId: vars.tradingAccountId,
marketId: fuzzMarketConfig.marketId,
sizeDelta: positionSize
})
);
changePrank({ msgSender: marketOrderKeepers[fuzzMarketConfig.marketId] });
perpsEngine.fillMarketOrder(
vars.tradingAccountId,
fuzzMarketConfig.marketId,
getMockedSignedReport(fuzzMarketConfig.streamId, initialPrice)
);
changePrank({ msgSender: users.naruto.account });
(vars.initialMarginBalance, vars.initialMarginRequired, vars.maintenanceMargin, ) = perpsEngine
.getAccountMarginBreakdown(vars.tradingAccountId);
// Initial conditions
assertEq(fuzzMarketConfig.maxFundingVelocity, 0.03e18, "Initial Funding Velocity is 0.03%");
assertAlmostEq(vars.initialMarginBalance.intoInt256(), 10_000e18, 1000e18); // Deposit - Order fees
assertAlmostEq(vars.initialMarginRequired.intoUint256(), 10_000e18, 5e18); // Includes fees
assertAlmostEq(vars.maintenanceMargin.intoUint256(), 5_000e18, 3e18); // // Includes fees
// This is very specific for demonstration purpose
// Could be a 20 minutes wait with a 3% price drop as well
vm.warp(block.timestamp + 8 hours);
priceFeed.updateMockPrice(9_966.1e18);
(vars.marginBalanceBeforeUpdate, , , ) = perpsEngine.getAccountMarginBreakdown(vars.tradingAccountId);
vars.fundingRateBeforeUpdate = perpsEngine.getFundingRate(fuzzMarketConfig.marketId);
vars.accruedFundingBeforeUpdate = getAccruedFundings(vars.tradingAccountId, positionSize, vars.fundingRateBeforeUpdate);
// Assert User still has an healthy position before upgrade
assertTrue(vars.marginBalanceBeforeUpdate.intoInt256() < vars.initialMarginRequired.intoSD59x18().intoInt256());
assertAlmostEq(vars.fundingRateBeforeUpdate.intoInt256(), 2.4e15, 1e13);
assertAlmostEq(vars.accruedFundingBeforeUpdate.intoInt256(), -800e18, 1e18);
assertFalse(
TradingAccountHarness(address(perpsEngine)).exposed_isLiquidatable(
vars.maintenanceMargin,
vars.marginBalanceBeforeUpdate
)
);
// Upgrade funding rate calculation
upgradeFundingRateCalculation(fuzzMarketConfig.maxFundingVelocity * 10); // x10 increases
(vars.marginBalanceAfterUpdate, , , ) = perpsEngine.getAccountMarginBreakdown(vars.tradingAccountId);
vars.fundingRateAfterUpdate = perpsEngine.getFundingRate(fuzzMarketConfig.marketId);
vars.accruedFundingAfterUpdate = getAccruedFundings(vars.tradingAccountId, positionSize, vars.fundingRateAfterUpdate);
assertAlmostEq(vars.fundingRateAfterUpdate.intoInt256(), 2.4e15, 1e14);
assertAlmostEq(vars.accruedFundingAfterUpdate.intoInt256(), -815e18, 1e18);
// State after upgrade
assertTrue(vars.marginBalanceAfterUpdate.intoInt256() < vars.marginBalanceBeforeUpdate.intoInt256()); // Margin Balance decreased
assertTrue(vars.accruedFundingAfterUpdate.intoInt256() < vars.accruedFundingBeforeUpdate.intoInt256()); // Accrued Funding decreased
assertTrue( // Account is now liquidatable
TradingAccountHarness(address(perpsEngine)).exposed_isLiquidatable(
vars.maintenanceMargin,
vars.marginBalanceAfterUpdate
)
);
}
function upgradeFundingRateCalculation(uint128 newMaxFundingVelocity) internal {
changePrank({ msgSender: users.owner.account });
// Simulate upgrading the maximum funding velocity
GlobalConfigurationBranch.UpdatePerpMarketConfigurationParams memory params = GlobalConfigurationBranch
.UpdatePerpMarketConfigurationParams({
name: fuzzMarketConfig.marketName,
symbol: fuzzMarketConfig.marketSymbol,
priceAdapter: fuzzMarketConfig.priceAdapter,
initialMarginRateX18: fuzzMarketConfig.imr,
maintenanceMarginRateX18: fuzzMarketConfig.mmr,
maxOpenInterest: fuzzMarketConfig.maxOi,
maxSkew: fuzzMarketConfig.maxSkew,
maxFundingVelocity: newMaxFundingVelocity,
minTradeSizeX18: fuzzMarketConfig.minTradeSize,
skewScale: fuzzMarketConfig.skewScale,
orderFees: fuzzMarketConfig.orderFees,
priceFeedHeartbeatSeconds: fuzzMarketConfig.priceFeedHeartbeatSeconds
});
perpsEngine.updatePerpMarketConfiguration(fuzzMarketConfig.marketId, params);
fuzzMarketConfig.maxFundingVelocity = newMaxFundingVelocity;
}
function getAccruedFundings(uint128 tradingAccountId, int128 positionSize, SD59x18 fundingRate) internal view returns (SD59x18 accruedFunding) {
SD59x18 fundingFeePerUnit = perpsEngine.exposed_getNextFundingFeePerUnit(
fuzzMarketConfig.marketId,
fundingRate,
perpsEngine.getMarkPrice(
fuzzMarketConfig.marketId,
perpsEngine.exposed_getIndexPrice(fuzzMarketConfig.marketId).intoUint256(),
positionSize
)
);
accruedFunding = PositionHarness(address(perpsEngine)).exposed_getAccruedFunding(
tradingAccountId,
fuzzMarketConfig.marketId,
fundingFeePerUnit
);
}
}

Impact

The impact of this vulnerability is severe due to the following reasons:

  • Immediate and unexpected increases in fees: Users with open positions will face a sudden modification in their funding fees, which can significantly affect their trading costs.

  • Immediate and unexpected Financial Losses: Sudden funding rate increases can push users’ margin balance below the maintenance margin requirement, triggering immediate liquidations and financial losses for users.

  • Erosion of User Trust: The unpredictability introduced by such changes can lead to a loss of trust in the platform. Users expect predictable and stable funding costs, and sudden changes can be seen as unfair or manipulative, leading to decreased user engagement and platform instability.

Tools Used

Manual testing

Recommendations

Consider introducing tracking the historical funding rates for each position. When calculating funding fees, consider the rate that was in effect during each elapsed time period, rather than applying the current rate retroactively.

This ensures that users have sufficient margin to withstand the increased funding costs.

Updates

Lead Judging Commences

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

When calling updatePerpMarketConfiguration, the fundingRate and the fundingFeePerUnit must be updated if the value for scewScale or maxFundingVelocity is changed

Support

FAQs

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

Give us feedback!