Unveiling the Hidden Flaws: OXORIO's Deep Dive into Rho Protocol's DeFi Derivatives

7 October, 2024
article image
Case Study

Contents

In April this year, the OXORIO team conducted an audit of Rho Protocol], which is a decentralized crypto-native interest rate derivatives market (we will delve deeper into this later). As a result, our auditors found many interesting vulnerabilities that we would like to share with you.

FYI, we have also found some interesting vulnerabilities in other types of protocols, see there :)

What are the derivatives?

Let’s begin our exploration into the realm of derivatives from a distant point, directly from Babylon. The earliest forms of derivatives emerged in Babylon, where merchants employed contracts obligating them to deliver goods in the future. These agreements served to mitigate risks associated with fluctuations in prices and product availability.

How did it work?

Let’s assume that a Babylonian farmer is anticipating a wheat harvest but is apprehensive about potential fluctuations in weather conditions that might adversely affect his yield. In order to mitigate the risk of a poor harvest, he could enter into an agreement (which, at that time, would have been more of a verbal contract) with a merchant to supply a specified quantity of wheat at a fixed price in the future.

Nowadays

In modern times, derivatives are also actively used in cryptocurrency, adhering to the same principle as in ancient times. They represent tradable financial contracts that derive their value from an underlying cryptocurrency, enabling traders to profit from fluctuations in the asset’s price without actually owning it.

Cryptocurrency derivatives function similarly to derivatives in the time of Babylon. Two parties enter into a contract that specifies the terms for buying or selling the underlying asset, including the contract’s expiration date, price, and quantity. Depending on the type of derivative, these contracts may obligate the buyer to purchase the asset and the seller to sell the asset. They may also require a specific transaction to occur on a certain date at a predetermined price.

More about crypto derivatives you can find here.

Derivatives can be categorized into three primary types:

  • Futures: these contracts allow traders to speculate on whether the price of an asset will be higher or lower at a specified future date.
  • Options: options provide the holder the right, but not the obligation, to buy or sell an underlying asset at a predetermined price on or before a specified date.
  • Perpetual futures (perps): unlike traditional futures with expiration dates, perps enable traders to maintain a position indefinitely until they choose to close it. These contracts often offer leverage to magnify potential profits or losses.

The focus of this article will be on perpetual futures, as they are the predominant derivative type utilized within DEXs.

Perpetual Futures Contracts

Also known as perps or perpetual swap contracts are similar to traditional futures contracts. However, there is a significant distinction between the two.

The primary difference between perpetual futures and traditional futures lies in the fact that perps do not have predetermined expiration dates. Traders can hold their perpetual contracts open for as long as they desire.

Let us delve into how perps operate using the example of Alice and Bob. Imagine Alice has 1 ETH and wants to speculate on ETH price. She decides to use 10x leverage to open a long position worth 10 ETH. Her 1 ETH serves as the initial margin.

If ETH price increases, Alice profits. Conversely, if the price drops significantly, her position’s value may fall below the maintenance margin. This could trigger a liquidation, where her position is automatically closed to prevent further losses. The liquidated collateral is added to the insurance fund.

There are many projects that were made thanks to this mechanism, and the Rho Protocol is one of them. However, it uses interest rate swaps, which are very similar to perpetual swaps.

How do interest rate swaps work?

  1. There are two parties involved in an interest rate swap:
  • Party A: This party has a fixed-rate loan. This means they pay the same interest rate throughout the loan term.
  • Party B: This party has a floating-rate loan. This means their interest rate changes over time based on market conditions.
  1. Swap Agreement.
  • The two parties agree to exchange their interest payments. Party A will pay Party B the floating interest rate on Party B’s loan, and Party B will pay Party A the fixed interest rate on Party A’s loan.

The core mechanisms driving the Rho protocol

Rho’s architecture revolves around several key components:

  • Rho Perpetuals: These are swap and futures contracts that provide continuous liquidity, ensuring efficient pricing and capital utilization.
  • Rho Pricing Engine (vAMM): This unique pricing mechanism is based on a modified XYK model, optimizing yield discovery in the derivatives market
  • Risk Management Engine: Rho’s risk management engine ensures the safety of the ecosystem while maximizing capital efficiency.
  • Permissioned Market Sections: These sections allow regulated firms to participate in the on-chain market while adhering to compliance requirements.
  • Compounded Rates: Rho aligns fixed and floating leg calculations to accurately represent financial returns over time.

Participants create interest rate swaps, specifying the swap term reset date, swap currency, and underlying floating rate. The Rho Oracle tracks the underlying floating rate (e.g., Libor or DeFi lending rates). The value of the virtual tokens is adjusted based on the tracked rates. At the swap term reset date, the net value of the virtual tokens is calculated, and payments are settled between the parties.

Virtual tokens are the tokens issued by the protocol itself in the form of a wrapper, they simplify the trading and settlement process. They are essentially digital contracts that represent the value of a specific financial instrument or exposure. The value of the virtual tokens is linked to the underlying financial instruments they represent.

How Virtual Tokens Work:

  • rflToken: Created with an initial value based on the underlying asset. Its value fluctuates based on the tracked index rate.
  • rfxToken: Represents a fixed payout at term reset.

    Participants agree on the terms of the swap, including the fixed rate and term.

    One participant receives rflTokens and pays rfxTokens (receiver).

    The other participant receives rfxTokens and pays rflTokens (payer).

    The value of the rflTokens is determined by the accrued floating rate.

    The rfxTokens pay out their fixed value.

    The difference between the two determines the swap’s fair value.

Here are some other terms we need to know before exploring vulnerabilities:

  • Initial Rate: The interest rate applied at the beginning of a futures contract.
  • Maker: A participant who places an order to buy or sell an asset at a specific price.
  • Taker: Participant who accepts an existing order placed by a maker.
  • Tick: The smallest increment of price change allowed in a financial market.
  • Notional: The face value or principal amount of a financial instrument, used to calculate interest payments.

For a deeper dive into the Rho protocol, we recommend checking out their Litepaper

Detailed Investigation of Serious Malfunctions

Based on the results of the audit we conducted, was identified a significant number of vulnerabilities. In this article, we will delve into the three most critical of these, which were successfully addressed by the Rho team.

However, if you are interested in exploring other vulnerabilities, you can find them in our public report :)

Rounding of the amounts of fixed and floating tokens exchanged in a trade

The issue stems from the calculation of fixedTokenDelta, which represents the amount of a fixed token a user needs to spend to get a certain amount of a floating token.

The contract’s approach to situations where the current and target prices are very close is problematic. In these cases, fixedTokenDelta can become so small that it’s rounded down to 0 due to calculation precision limits. This can lead to situations where a user can get floating tokens without spending any fixed tokens, effectively allowing them to exploit the DEX.

In the calcSwapStepState the function of the SwapLogic contract, after determining the new price, fixedTokenDelta is calculated for the current interval. It depends on the difference between the current and new prices:

function calcSwapStepState(CalcSwapStepStateParams memory params) public pure returns (StepState memory result) {

// ...

result.fixedTokenDelta = nextPointFloatAmount == PrbMath.UNSIGNED_ZERO
  ? PrbMath.UNSIGNED_ZERO
  : calcXAmountSqrt(intervalLiquidity, currentPriceSqrt, targetPriceWithSqrt.priceSqrt);

// ...

}

The formula for calculating fixedTokenDelta can be simplified as follows:

fixedTokenDelta = L * (1/sqrt(targetPrice) - 1/sqrt(currentPrice))

// L - liquidity on the current interval
// targetPrice - target price after swap

From the formula, it is clear that when the current and target prices are equal, fixedTokenDelta will be zero. However, the contract code has an additional check for a zero fixedTokenDelta value, which is performed after the entire swap is completed, not on each interval.

This implementation can lead to a situation where a user can get a certain amount of a floating token (floatTokenAmount) without a corresponding decrease in the amount of a fixed token (fixedTokenDelta). This is possible when the current and target prices are almost equal, which can happen when exchanging a very small amount of a floating token with high liquidity in the interval:

function calcSwapStepState(CalcSwapStepStateParams memory params) public pure returns (StepState memory result) {

// ...

targetPriceWithSqrt.priceSqrt = params.direction == RiskDirection.Value.PAYER
	? calcTargetPriceSqrtByYAmount(params.targetFloatTokenAmount, intervalLiquidity, currentPriceSqrt, false)
	: calcTargetPriceSqrtByYAmount(params.targetFloatTokenAmount, intervalLiquidity, currentPriceSqrt, true);

// ...

}

The formula can be simplified as follows:

sqrtTargetPrice = sqrtCurrentPrice ± (floatTokenAmount/liquidity)

When the liquidity is high and the floatTokenAmount is very small, the fraction floatTokenAmount/liquidity can become so small that when rounded it will be equal to 0. As a result, the target price will be almost equal to the current price, leading to a zero fixedTokenDelta.

Let’s look at an example to understand this better:

  1. The current price of a futures contract is 0.1, the intervalLength is 0.001, the floatIndex is 1.1, and the token’s decimal place is 6.
  2. A maker provides liquidity in two intervals:
  • 1,000,000 tokens for the interval [0.1; 0.101]
  • 10,000,000,000,000,000,000 tokens for the interval [0.101; 0.102]
  1. A taker creates a long position, exchanging 2 tokens:
  • They exchange 1,000,000 tokens in the interval [0.1; 0.101]
  • And another 1,000,000 tokens in the interval [0.101; 0.102]
  1. During the swap on the second interval, the taker exchanges 1,000,000 tokens, which means floatTokenAmount = 1_token / floatIndex = 909091. At the same time, the liquidity in the interval will be L = 2314927140438373688847623.
  2. As a result of the division floatTokenAmount/L = 909091/2314927140438373688847623 = 0, we get 0 due to rounding a very small value, and in turn:
sqrtTargetPrice == sqrtCurrentPrice
  1. As a result, the taker received floatTokenAmount = 909091, but spent fixedTokenDelta = 0.

This scenario can be repeated multiple times. To do this, the taker only needs to create a short position to return the price to the interval [0.1; 0.101] and then create a long position again.

How to fix

  1. Find out the conditions under which the fixedTokenDelta can become zero after a trade.
  2. Review the ways numbers are rounded after calculations.

Multiple unsettled futures resulting in a gas bomb

Over time, a multitude of futures contracts, both active and settled, are formed on the market. Since only the user can close their futures, a significant accumulation of unresolved positions is possible. In the case of Rho, this is possible through the provideLiquidity ortransferPositionsOwnership functions. This creates a serious problem for the protocol, especially during liquidation.

It becomes simply unprofitable to liquidate small user positions with many unresolved futures because the functions (transferPositionsOwnership and liquidatePositions) consume a lot of gas. As a result, even with high gas fees, such positions may remain open.

Additionally, the presence of multiple loops over unresolved futures in resource-intensive functions exacerbates the situation:

  1. The liquidatePositions function contains a loop over unresolved futures in the `margin` function with several external calls, and calculations of user profit and loss.
  2. Then, the initialMarginThreshold function has a loop over unresolved futures.
  3. Next, there is a loop in the lpMarginThreshold function, followed by a loop in the liquidationMarginThreshold function with several external calls.
  4. After that, there is another loop in the _performLiquidationTrades function with resource-intensive logic and external calls.
  5. Immediately after that, there are another call to margin and initialMarginThreshold with loops.
  6. Finally, in the MarginUpdate event, there is another call to margin.
function liquidatePositions( PositionsLiquidationParams memory params ) external returns (TradeInfo[] memory tradesInfo, UD60x18 reward) {

// ...

 MarginWithThresholds memory ownerMarginInfoBefore = MarginWithThresholds({
        currentMargin: market.margin(cache.ownerUnsettledFutures, params.owner, cache.floatIndex), // 1 - loop in the margin function
        initialMarginThreshold: intoSD59x18(
            market.initialMarginThreshold(cache.ownerUnsettledFutures, params.owner, cache.floatIndex) // 2 - loop in the initialMarginThreshold function
        ),
        lpMarginThreshold: intoSD59x18(
            market.lpMarginThreshold(cache.ownerUnsettledFutures, params.owner, cache.floatIndex) // 3 - loop in the lpMarginThreshold function
        ),
        liquidationThreshold: intoSD59x18(
            market.liquidationMarginThreshold(cache.ownerUnsettledFutures, params.owner, cache.floatIndex) // 3 - loop in the function liquidationMarginThreshold
        )
    });

    if (ownerMarginInfoBefore.currentMargin.total() >= ownerMarginInfoBefore.liquidationThreshold) {
        revert IFutureErrors.ParticipantIsNotLiquidatable();
    }

    tradesInfo = _performLiquidationTrades(params, cache, ownerMarginInfoBefore); // 4 - loop in _performLiquidationTrades

    MarginWithThreshold memory ownerMarginInfoAfter = MarginWithThreshold({
        currentMargin: market.margin(cache.ownerUnsettledFutures, params.owner, cache.floatIndex), // 5 - another loop in the margin function
        marginThreshold: intoSD59x18(
            market.initialMarginThreshold(cache.ownerUnsettledFutures, params.owner, cache.floatIndex) // 5 - another loop in the initialMarginThreshold function
        )
    });

// ...

        emit RouterEvents.MarginUpdate(
            params.marketId,
            msg.sender,
            msg.sender,
            intoSD59x18(reward),
            market.margin(liquidatorUnsettledFutures, msg.sender, cache.floatIndex),
            // 6 - another loop in the margin function
            totalCollateral
        );

// ...

}

In total, the liquidatePositions function contains at least 8 loops over the user’s unresolved futures. This means that to liquidate a user with only 3 active futures, the function needs to loop through each of these futures 24 times, making several external calls and performing other resource-intensive operations.

Such architecture creates a risk of a gas bomb attack, where a user creates a large number of futures to make the liquidation function revert.

How to fix

  1. Change the current structure of the protocol.
  2. Introduce limits on the number of unresolved futures a user can have.

Tick handling can lead to losses near interval boundaries

The provideLiquidity function in the VAMM contract incorrectly handles cases where the price is at the boundary between two intervals. This results in liquidity being added to the same interval twice, which can lead to serious imbalances and instability in the system.

For example, consider this scenario:

  1. Initial rate - 0.1.

  2. A maker adds liquidity to the interval 0.099-0.1 with a nominal value of 1. The variable intervalLiquidity, calculated based on density, is equal to 10583236255.

  3. A taker opens a short position with a nominal value of 1. During the execution of the swap function, two tick crossings will occur. The first crossing will occur with the interval 0.1-0.101 (since 0.1 was the initial) to start the swap. However, since the swap is executed for a nominal value of 1 (which is the entire amount on the interval), the resulting rate will become 0.099. This rate is equal to the upper bound of the next interval, and another crossing will occur:

function calcSwapStepState(CalcSwapStepStateParams memory params) public pure returns (StepState memory result) {

// ...

result.needToCross = result.rate == result.intervalInfo.nextRate;

// ...

}
  1. After this, the current rate is still 0.099, but the current interval is 0.098-0.099.

  2. The maker adds liquidity again to the interval 0.099-0.1 with a nominal value of 1. When adding liquidity, we fall into the if condition, since the interval 0.099-0.1 is within the current tick 0.099:

  function provideLiquidity(
    address to,
    UD60x18 notional,
    UD60x18 minNotional,
    RateBounds calldata bounds
  )
    external
    override
    checkMinNotional(notional, minNotional)
    checkBounds(bounds)
    onlyViaRelatedFuture
    returns (LiquidityProvisionResult memory result)
  {

// ...

if (currentRate >= bounds.lower && currentRate < bounds.upper) {
  _storage.setCurrentIntervalNotionalDensity(_storage.getCurrentIntervalNotionalDensity() + notionalDensityDelta);
}

// ...

}

However, the function getCurrentIntervalNotionalDensity will return a density value for the interval 0.098-0.099, even though in reality, liquidity should be added to the interval 0.099-0.1, where its value should be 1000000. This discrepancy is caused by a premature update of the VAMM state in the previous step.

  1. A taker opens a long position for 3 notional, and the first step of the swap is executed with an intervalLiquidity value of 10572652009, and then another intersection is performed with an additional intervalLiquidity of 31749742022, created out of nowhere. Under normal conditions, intervalLiquidity should be equal to the value of 21166490801, which is the actual liquidity provided by VAMM. This notional is created out of thin air, allowing for the creation of an infinite amount of liquidity.

Liquidations Front-running

Similar to the incorrect handling of ticks in the VAMM contract, in the liquidatePositions function in the LiquidationLogic contract, users can front-run liquidations by setting the VAMM state when the price is at the boundary of the interval. This leads to a rollback during liquidation because the removeLiquidity function in the VAMM contract will cause a rollback.

The liquidatePositions function will also rollback since the removeLiquidity function is called with a call to the _liquidateAllFutureProvisions function.

Let’s break it down with an example:

  1. The liquidator starts liquidating an underwater position by calling liquidatePositions.

  2. The attacker gets ahead of the liquidation by setting the VAMM state when the price is at the boundary of the interval.

  3. The liquidation function executes and rolls back during the call to the removeLiquidity function. Liquidity cannot be removed from the interval because during the removal of liquidity, the code enters the if statement:

  function removeLiquidity(
    address from,
    UD60x18 notional,
    UD60x18 minNotional,
    RateBounds calldata bounds
  )
    external
    override
    checkMinNotional(notional, minNotional)
    checkBounds(bounds)
    onlyViaRelatedFuture
    returns (LiquidityRemovalResult memory result)
  {

// ...

    if (currentRate >= bounds.lower && currentRate < bounds.upper) {
      _storage.setCurrentIntervalNotionalDensity(_storage.getCurrentIntervalNotionalDensity() - notionalDensityDelta);
    }

// ...

}
  1. The getCurrentIntervalNotionalDensity function returns the notional density of the previous interval compared to the interval from which liquidity is being attempted to be removed.

  2. The removeLiquidity function returns a value of 0 for the previous interval liquidity (for example, if there is no liquidity in the previous interval), while it should have returned the amount of the current interval liquidity notional value.

  • This makes it impossible to liquidate users until theVAMM state is changed to the correct tick.
  1. When the liquidation transaction fails, the attacker hedges their previous position.

How to fix

  1. Introduce additional checks for cases where the price is at the interval boundary.
  2. Add flags to the VAMM contract storage to track the price state.
  3. Handle cases where the price is equal to the lower interval boundary separately from other cases.
  4. Make changes to the provideLiquidity, removeLiquidity functions. (See the report for more details)
  5. Conduct fuzzing tests to verify the correct operation of the contracts.

Conclusion

Derivatives are a big part of decentralized systems and the rise of DEXes. While they might seem simple, derivatives are actually built upon complex and extensive code, which can contain critical vulnerabilities that threaten protocol security.

Always be mindful of potential risks and conduct regular audits of your code!

Telegram
Case Study

Contents

Telegram

Have a question?

Have a question?

Stay Connected with OXORIO

We're here to help and guide you through any inquiries you might have about blockchain security and audits.