Prepared by:
HALBORN
Last Updated 09/05/2024
Date of Engagement by: June 7th, 2024 - July 26th, 2024
100% of all REPORTED Findings have been addressed
All findings
20
Critical
0
High
2
Medium
2
Low
4
Informational
12
RFX Exchange
engaged Halborn to conduct a security assessment on their smart contracts beginning on June 7th, 2024 and ending on July 26th, 2024. The security assessment was scoped to smart contracts in the GitHub repository provided to the Halborn team. Commit hashes and further details can be found in the Scope section of this report.
Halborn assigned two full-time security engineers to review the security of the smart contracts in scope. The engineers are blockchain and smart contract security experts with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the assessment is to:
Identify potential security issues within the smart contracts.
Ensure that smart contract functionality operates as intended.
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the RFX team
. The main ones were the following:
Include the current value of the execution fee when topping up subaccounts during order updates.
Update the funding and borrowing state during deposits / withdrawals before modifying the pool amount.
Validate that the oracle prices provided during the execution of the operation fall within an acceptable range.
Remove the deposit cap validation if the price impact is greater than zero.
Halborn
performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the contracts' solidity code and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the assessment:
Research into architecture and purpose.
Smart contract manual code review and walk-through.
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic-related vulnerability classes.
Local testing with custom scripts (Foundry
).
Fork testing against main networks (Foundry
).
Static analysis of security for scoped contract, and imported functions.
This assessment was specifically focused on the contracts defined within the scope, and therefore, the findings are limited to those contracts. However, there are numerous other contracts that were not included in this assessment but are part of the overall system.
Issues or vulnerabilities in these out-of-scope contracts could potentially affect the security of the protocol. Consequently, it is recommended to complement this assessment with a comprehensive review of the entire codebase to identify any additional vulnerabilities or issues that may not have been covered, ensuring a more robust security posture for the protocol.
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
2
Medium
2
Low
4
Informational
12
Security analysis | Risk level | Remediation Date |
---|---|---|
Execution fee might not be refunded to subaccounts when updating orders | High | Solved - 09/01/2024 |
Non-updated borrowing factor leads to fee miscalculations | High | Solved - 08/21/2024 |
Lack of price validation allows operations at improper prices | Medium | Solved - 08/21/2024 |
Deposit cap validation could cause unnecessary reverts | Medium | Solved - 08/21/2024 |
Flawed payment logic | Low | Solved - 08/21/2024 |
Oracle feed reports missing feed ID | Low | Solved - 08/21/2024 |
Role set is not correctly updated when revoking one of the roles | Low | Solved - 09/01/2024 |
Untimely balance checks when topping up subaccounts | Low | Solved - 09/01/2024 |
Empty event log data is returned when processing order increases | Informational | Solved - 09/01/2024 |
Lack of zero address check | Informational | Acknowledged - 09/01/2024 |
Potential variable declaration might go unused | Informational | Solved - 09/01/2024 |
Duplicated functions | Informational | Solved - 09/01/2024 |
Optimization in conditional logic | Informational | Solved - 09/01/2024 |
Optimizations in loops | Informational | Acknowledged - 09/01/2024 |
Unlocked pragma | Informational | Acknowledged - 09/01/2024 |
Unused components in the codebase | Informational | Partially Solved - 08/21/2024 |
Incorrect or missing NatSpec comments | Informational | Partially Solved - 08/21/2024 |
Typo In comment | Informational | Solved - 08/21/2024 |
Incorrect comments | Informational | Solved - 08/21/2024 |
Unused import statements | Informational | Partially Solved - 08/30/2024 |
// High
When updating an order through the SubaccountRouter contract, a subaccount needs to spend some gas and, sometimes, an execution fee
that must be deposited in advance. The gas spent is refunded to the subaccount, but the execution fee is not. To set the context, it is important to review the top-up mechanism of the protocol when a subaccount creates or updates an order through the SubaccountRouter contract.
When a subaccount creates an order, it needs to spend gas + an execution fee
. This total value is topped up (i.e., refunded) up to a certain cap to the subaccount after the order is created.
On the other hand, when a subaccount updates an order, only the spent gas is refunded, not the execution fee. Sometimes, the subaccount doesn't need to add more execution fee, only to modify some parameters. However, in other scenarios is a must to add more execution fee to the orders, as seen in the following example:
An order is created.
A keeper executes the order, but it fails because it is unfulfillable. So, the execution fee
for the order is now set to 0 and the order is frozen.
To unfreeze the order, the subaccount needs to update it. Since the current the current execution fee
is 0 (see previous step), the subaccount must have previously deposited an execution fee to incentivize keepers to execute it.
Finally, the gas spent is topped up (i.e., refunded) to the subaccount, but not the execution fee.
The execution fee
that subaccounts need to deposit in advance when updating orders is not considered when topping them up. As a result, the execution fee spent might not be correctly refunded to the subaccounts, which economically harms the subaccounts and causes them to lose tokens.
The described issue happens because the SubaccountRouter.updateOrder
function calls _autoTopUpSubaccount
using 0 as execution fee, instead of relying on the amount of wrapped native tokens previously deposited by the subaccount:
_autoTopUpSubaccount(
order.account(), // account
msg.sender, // subaccount
startingGas, // startingGas
0 // executionFee
);
It means that when nativeTokensUsed
is calculated, it will consider executionFee
as 0, so the amount transferred to the subaccount
could be missing the executionFee
:
// cap the top up amount to the amount of native tokens used
uint256 nativeTokensUsed = (startingGas - gasleft()) * tx.gasprice + executionFee;
if (nativeTokensUsed < amount) { amount = nativeTokensUsed; }
router.pluginTransfer(
address(wnt), // token
account, // account
address(this), // receiver
amount // amount
);
TokenUtils.withdrawAndSendNativeToken(
dataStore,
address(wnt),
subaccount,
amount
);
However, the OrderHandler.updateOrder
function (which is called at some point during the order update operation) adds a new executionFee
equivalent to the wrapped native tokens previously sent, which can be greater than zero, especially if the order was previously frozen (which set the executionFee
to 0) and needs that someone adds a new executionFee
greater than zero to make it work:
// allow topping up of executionFee as frozen orders
// will have their executionFee reduced
address wnt = TokenUtils.wnt(dataStore);
uint256 receivedWnt = orderVault.recordTransferIn(wnt);
order.setExecutionFee(order.executionFee() + receivedWnt);
The proof of concept was directly developed in the already existent test/guardian/testFrozenOrder.ts
test file, which only needs a couple of minor updates to make the PoC work correctly.
Updates in the test file:
Add the following imports:
import { createAccount } from "../../utils/account";
import * as keys from "../../utils/keys";
Finally, include subaccountRouter
and orderVault
variables in the test file
Step-by-step explanation of the PoC:
The subaccountRouter
is set. It is configured that the subaccount's top-up amount is 0.5 ETH, which is the cap when refunding to subaccounts.
An order is created with 0.3 ETH as execution fee
.
The order is executed, but it fails because it is unfulfillable. So, the execution fee
for the order is now set to 0 and the order is frozen.
The subaccount updates the order for the first time without depositing wrapped native tokens. As expected, the order is unfrozen but the execute fee
is still 0. Then, after refunding, the subaccount's balance has been reduced by only 0.000641938502995713 ETH (partial gas expenses).
Finally, the subaccount updates the order for the second time, but now depositing 0.3 ETH as execution fee
. As expected, the execute fee
is now 0.3 ETH. However, after refunding, the subaccount's balance has now been reduced by 0.000667564503115301 ETH (partial gas expenses), but also by the execution fee
(0.3 ETH).
it("Execution fee is not refunded to subaccount when updating order", async () => {
const subaccount = createAccount();
// Setting subaccountRouter
await subaccountRouter
.connect(user1)
.multicall(
[
subaccountRouter.interface.encodeFunctionData("sendNativeToken", [subaccount.address, expandDecimals(1, 18)]),
subaccountRouter.interface.encodeFunctionData("addSubaccount", [subaccount.address]),
subaccountRouter.interface.encodeFunctionData("setMaxAllowedSubaccountActionCount", [
subaccount.address,
keys.SUBACCOUNT_ORDER_ACTION,
20,
]),
/**** STEP 1: SETTING THE SUBACCOUNT TOP-UP AMOUNT ****/
subaccountRouter.interface.encodeFunctionData("setSubaccountAutoTopUpAmount", [
subaccount.address,
expandDecimals(5, 17), // Top up to 0.5 ETH
]),
],
{ value: expandDecimals(1, 18) }
);
/**** STEP 2: A LIMIT INCREASE ORDER IS CREATED ****/
await createOrder(fixture, {
account: user1,
market: ethUsdMarket,
initialCollateralToken: wnt,
initialCollateralDeltaAmount: expandDecimals(1, 18),
sizeDeltaUsd: decimalToFloat(10_000),
acceptablePrice: expandDecimals(5100, 12),
triggerPrice: expandDecimals(5000, 12),
orderType: OrderType.LimitIncrease,
isLong: true,
executionFee: expandDecimals(3, 17),
});
// Limit order go to future block
await mine(10);
const orderKeys = await getOrderKeys(dataStore, 0, 1);
const orderBeforeExecuted = await reader.getOrder(dataStore.address, orderKeys[0]);
// Check that the order's execution fee is 3e17, i.e.: 0.3 ETH
expect(orderBeforeExecuted.numbers.executionFee).eq(expandDecimals(3, 17));
/**** STEP 3: THE ORDER IS EXECUTED AND BECOMES FROZEN ****/
await executeOrder(fixture, {
tokens: [wnt.address, usdc.address],
minPrices: [expandDecimals(5000, 4), expandDecimals(1, 6)],
maxPrices: [expandDecimals(5000, 4), expandDecimals(1, 6)],
tokenOracleTypes: [TOKEN_ORACLE_TYPES.DEFAULT, TOKEN_ORACLE_TYPES.DEFAULT],
precisions: [8, 18],
expectedFrozenReason: "InsufficientReserve",
});
const orderPreUpdate = await reader.getOrder(dataStore.address, orderKeys[0]);
// Check that the order's execution fee is now 0
expect(orderPreUpdate.numbers.executionFee).eq(0);
// Check that the order has been frozen
expect(orderPreUpdate.flags.isFrozen).eq(true);
// ETH balance for subaccount before updating order
const ethBalancePreUpdate = await ethers.provider.getBalance(subaccount.address);
/**** STEP 4: SUBACCOUNT UPDATES THE ORDER FOR THE FIRST TIME ****/
// subaccount updates the order to be filled at a different price points this will unfreeze the order
await subaccountRouter.connect(subaccount).updateOrder(
orderKeys[0],
decimalToFloat(10_000), // sizeDeltaUsd
expandDecimals(5200, 12), // acceptablePrice
expandDecimals(5200, 12), // triggerPrice
expandDecimals(52000, 6) // minOutputAmount
);
const orderAfterFirstUpdate = await reader.getOrder(dataStore.address, orderKeys[0]);
// Check that the order's execution fee is still 0
expect(orderAfterFirstUpdate.numbers.executionFee).eq(0);
// Check that the order has been unfrozen
expect(orderAfterFirstUpdate.flags.isFrozen).eq(false);
// ETH balance for subaccount after first update
const ethBalanceAfterFistUpdate = await ethers.provider.getBalance(subaccount.address);
// Gas spent (after top-up) in the first update: 0.000641938502995713 ETH
const EthSpentFirstUpdate = ethers.BigNumber.from("641938502995713");
// The difference between the previous and current balance is due to gas expenses
expect(ethBalancePreUpdate.sub(ethBalanceAfterFistUpdate)).eq(EthSpentFirstUpdate);
/**** STEP 5: SUBACCOUNT UPDATES THE ORDER FOR THE SECOND TIME ****/
await subaccountRouter
.connect(subaccount)
.multicall(
[
subaccountRouter.interface.encodeFunctionData("sendWnt", [orderVault.address, expandDecimals(3, 17)]),
subaccountRouter.interface.encodeFunctionData("updateOrder",
[
orderKeys[0],
decimalToFloat(10_000), // sizeDeltaUsd
expandDecimals(5200, 12), // acceptablePrice
expandDecimals(5200, 12), // triggerPrice
expandDecimals(52000, 6) // minOutputAmount
]),
],
{ value: expandDecimals(3, 17) }
);
const orderAfterSecondUpdate = await reader.getOrder(dataStore.address, orderKeys[0]);
// Check that the order's execution fee is now 3e17 again, i.e.: 0.3 ETH
expect(orderAfterSecondUpdate.numbers.executionFee).eq(expandDecimals(3, 17));
// ETH balance for subaccount after second update
const ethBalanceAfterSecondUpdate = await ethers.provider.getBalance(subaccount.address);
// Gas spent (after top-up) in the second update: 0.000667564503115301 ETH
const EthSpentSecondtUpdate = ethers.BigNumber.from("667564503115301");
// The difference between the previous and current balance is due to execution fee (0.3 ETH) + gas expenses
// TL;DR: This difference shows that the execution fee is not refunded to the subaccount
expect(ethBalanceAfterFistUpdate.sub(ethBalanceAfterSecondUpdate)).eq(orderAfterSecondUpdate.numbers.executionFee.add(EthSpentSecondtUpdate));
});
Include the current value of the execution fee when topping up subaccounts during order updates.
SOLVED: The RFX team solved the issue in the specified commit id.
// High
When a position is updated, the updateFundingAndBorrowingState
function is triggered to adjust the CUMULATIVE_BORROWING_FACTOR
. This factor is updated based on the latest borrowing rate (i.e.: borrowing factor per second) and the elapsed time since the last update. The borrowing rate itself depends on the percentage of the pool's liquidity that is currently borrowed: the higher the borrowed percentage, the higher the rate. By updating the CUMULATIVE_BORROWING_FACTOR
before making any state changes that could influence the rate, the system ensures that borrowing fees are calculated accurately.
However, this process is not consistently applied throughout the code. When users withdraw or deposit funds, they alter the borrowing rate: withdrawals increase it, while deposits decrease it. Since the CUMULATIVE_BORROWING_FACTOR
is not updated during a deposit or withdrawal, the next time it is updated (e.g.: when increasing / decreasing a position), the borrowing fees will be miscalculated because the rate will use the new value instead of the actual value that was present during the elapsed time. The described situation can result in excessive fees being charged if a withdrawal occurs, or insufficient fees being charged if a deposit occurs. The affected functions are the following:
ExecuteDepositUtils.executeDeposit
ExecuteWithdrawalUtils.executeWithdrawal
It is worth noting that large deposits or withdrawals can significantly amplify this fee inaccuracy. Excessive fees can unexpectedly push near-liquidateable positions into liquidation. Furthermore, this incremental rise in borrowing fees creates arbitrage opportunities, allowing attackers to exploit the inaccurate fee adjustments through well-timed orders and deposits.
Update the funding and borrowing state in the executeDeposit
and executeWithdrawal
functions before modifying the pool amount.
SOLVED: The RFX team solved the issue when refactoring the codebase to v2.1.
// Medium
When creating a deposit, withdrawal, or order, the user's funds are transferred into a vault. A key referencing the requested operation is attached to the corresponding event upon creation. Once this event is detected, a keeper is triggered. The order keeper can then execute the requested operation by providing the necessary request key and oracle prices.
While prices are validated to ensure they are appropriate at the time of execution, they are not validated to ensure they fall within an acceptable range compared to the time of request creation. As a result, if there is a significant time gap between the request creation and execution, the operation may be executed with inappropriate prices. This scenario can potentially harm both users and disrupt the market's balance.
Let's take the example of deposits: In order to execute a deposit, an order keeper calls the executeDeposit
function within the exchange/DepositHandler
contract:
function executeDeposit(
bytes32 key,
OracleUtils.SetPricesParams calldata oracleParams
) external payable
globalNonReentrant
onlyOrderKeeper
withOraclePrices(oracle, dataStore, eventEmitter, oracleParams)
{
...
}
The OracleModule.withOraclePrices
modifier is implemented as follows: it sets the oracle prices using the provided prices before executing the calling function, and subsequently clears all the prices:
modifier withOraclePrices(
Oracle oracle,
DataStore dataStore,
EventEmitter eventEmitter,
OracleUtils.SetPricesParams memory params
) {
oracle.setPrices{value: msg.value}(dataStore, eventEmitter, params);
_;
oracle.clearAllPrices();
}
The Oracle.setPrices
function has the below implementation:
function setPrices(
DataStore dataStore,
EventEmitter eventEmitter,
OracleUtils.SetPricesParams memory params
) external payable onlyController {
if (tokensWithPrices.length() != 0) {
revert Errors.NonEmptyTokensWithPrices(tokensWithPrices.length());
}
_setPricesFromRealtimeFeeds(dataStore, eventEmitter, params);
}
The Oracle._setPricesFromRealtimeFeeds
function has the below implementation:
function _setPricesFromRealtimeFeeds(
DataStore dataStore,
EventEmitter eventEmitter,
OracleUtils.SetPricesParams memory params
) internal returns (OracleUtils.RealtimeFeedReport[] memory) {
OracleUtils.RealtimeFeedReport[] memory reports = _validateRealtimeFeeds(
dataStore,
params.realtimeFeedTokens,
params.realtimeFeedData
);
....
}
The Oracle._validateRealtimeFeeds
function has the below implementation:
function _validateRealtimeFeeds(
DataStore dataStore,
address[] memory realtimeFeedTokens,
bytes[] memory realtimeFeedData
) internal returns (OracleUtils.RealtimeFeedReport[] memory) {
...
uint256 dataCounter;
for (uint256 i; i < realtimeFeedTokens.length; i++) {
...
uint fee = pyth.getUpdateFee(feedData);
/// @dev Reverts if the transferred fee is not sufficient or the updateData is invalid or there is
/// no update for any of the given `priceIds` within the given time range
uint64 minPublishTime = uint64(Chain.currentTimestamp() - maxPriceAge);
uint64 maxPublishTime = uint64(Chain.currentTimestamp() + maxPriceAge);
PythStructs.PriceFeed[] memory priceFeeds = pyth.parsePriceFeedUpdates{value: fee}(feedData, feedIds, minPublishTime, maxPublishTime);
...
}
...
}
The highlighted part will ensure that the prices provided to pyth.parsePriceFeedUpdates
must fall within the specified minPublishTime
and maxPublishTime
. These timestamps are derived from the current block timestamp, thereby enforcing that the prices are within an acceptable range relative to the time of execution.
Going back to the exchange/DepositHandler.executeDeposit
function, it later calls the _executeDeposit
internal function, which calls the ExecuteDepositUtils.executeDeposit
function, which also fails to confirm that the provided prices align with an acceptable timeframe relative to the deposit creation time:
function executeDeposit(ExecuteDepositParams memory params, Deposit.Props memory deposit) external {
// 63/64 gas is forwarded to external calls, reduce the startingGas to account for this
params.startingGas -= gasleft() / 63;
DepositStoreUtils.remove(params.dataStore, params.key, deposit.account());
ExecuteDepositCache memory cache;
if (deposit.account() == address(0)) {
revert Errors.EmptyDeposit();
}
Market.Props memory market = MarketUtils.getEnabledMarket(params.dataStore, deposit.market());
_validateFirstDeposit(params, deposit, market);
MarketUtils.MarketPrices memory prices = MarketUtils.getMarketPrices(params.oracle, market);
MarketUtils.distributePositionImpactPool(
params.dataStore,
params.eventEmitter,
market.marketToken
);
...
Consider validating that the oracle prices provided during the execution of the operation fall within an acceptable range relative to the prices at the time the operation was requested.
SOLVED: The RFX team solved the issue when refactoring the codebase to v2.1.
// Medium
The _executeDeposit
function in the ExecuteDepositUtils
library calls the MarketUtils.validatePoolAmountForDeposit
function if priceImpactUsd
is greater than zero. However, if the pool's long token is near its deposit cap while the short token is not, depositing short tokens can lead to a positive price impact. This price impact might cause the long token's deposit cap to be exceeded and, as a consequence, the operation could unnecessarily revert.
In such scenarios, it is preferable to allow the pool to be rebalanced even if it results in surpassing the deposit cap for the long token.
A call to the MarketUtils.validatePoolAmountForDeposit
function when priceImpactUsd
is greater than zero could cause an unnecessary revert:
if (_params.priceImpactUsd > 0) {
// when there is a positive price impact factor,
// tokens from the swap impact pool are used to mint additional market tokens for the user
// for example, if 50,000 USDC is deposited and there is a positive price impact
// an additional 0.005 ETH may be used to mint market tokens
// the swap impact pool is decreased by the used amount
//
// priceImpactUsd is calculated based on pricing assuming only depositAmount of tokenIn
// was added to the pool
// since impactAmount of tokenOut is added to the pool here, the calculation of
// the price impact would not be entirely accurate
//
// it is possible that the addition of the positive impact amount of tokens into the pool
// could increase the imbalance of the pool, for most cases this should not be a significant
// change compared to the improvement of balance from the actual deposit
int256 positiveImpactAmount = MarketUtils.applySwapImpactWithCap(
params.dataStore,
params.eventEmitter,
_params.market.marketToken,
_params.tokenOut,
_params.tokenOutPrice,
_params.priceImpactUsd
);
// calculate the usd amount using positiveImpactAmount since it may
// be capped by the max available amount in the impact pool
// use tokenOutPrice.max to get the USD value since the positiveImpactAmount
// was calculated using a USD value divided by tokenOutPrice.max
//
// for the initial deposit, the pool value and token supply would be zero
// so the market token price is treated as 1 USD
//
// it is possible for the pool value to be more than zero and the token supply
// to be zero, in that case, the market token price is also treated as 1 USD
mintAmount += MarketUtils.usdToMarketTokenAmount(
positiveImpactAmount.toUint256() * _params.tokenOutPrice.max,
poolValue,
marketTokensSupply
);
// deposit the token out, that was withdrawn from the impact pool, to mint market tokens
MarketUtils.applyDeltaToPoolAmount(
params.dataStore,
params.eventEmitter,
_params.market,
_params.tokenOut,
positiveImpactAmount
);
MarketUtils.validatePoolAmountForDeposit(
params.dataStore,
_params.market,
_params.tokenOut
);
MarketUtils.validatePoolAmount(
params.dataStore,
_params.market,
_params.tokenOut
);
}
It is recommended to remove the call to the MarketUtils.validatePoolAmountForDeposit
function if priceImpactUsd
is greater than zero.
SOLVED: The RFX team solved the issue when refactoring the codebase to v2.1.
// Low
The Oracle contract relies on the Pyth Network for querying price feeds using pyth.parsePriceFeedUpdates
, which necessitates a pre-calculated fee. However, the current payment logic has several issues:
The _validateRealtimeFeeds
function does not validate whether the caller has provided sufficient ETH to cover the fees when calculating the fee. This oversight allows the caller to utilize contract funds and bypass the payment requirement.
The validateRealtimeFeeds
function, which incurs fees by calling _validateRealtimeFeeds
, lacks the payable
modifier. Consequently, it does not allow callers to attach the necessary funds to cover fees. If the contract has insufficient funds, this function becomes temporarily disabled. Since the Oracle
contract lacks a payable fallback or receive function, funds can only be transferred by either self-destructing from another contract to transfer funds or by attaching a large amount of ETH when calling setPrices
, the only available payable function.
The _validateRealtimeFeeds
function does not validate whether the caller has provided sufficient ETH to cover the fees:
function _validateRealtimeFeeds(
DataStore dataStore,
address[] memory realtimeFeedTokens,
bytes[] memory realtimeFeedData
) internal returns (OracleUtils.RealtimeFeedReport[] memory) {
...
for (uint256 i; i < realtimeFeedTokens.length; i++) {
...
uint fee = pyth.getUpdateFee(feedData);
/// @dev Reverts if the transferred fee is not sufficient or the updateData is invalid or there is
/// no update for any of the given `priceIds` within the given time range
uint64 minPublishTime = uint64(Chain.currentTimestamp() - maxPriceAge);
uint64 maxPublishTime = uint64(Chain.currentTimestamp() + maxPriceAge);
PythStructs.PriceFeed[] memory priceFeeds = pyth.parsePriceFeedUpdates{value: fee}(feedData, feedIds, minPublishTime, maxPublishTime);
...
}
...
}
The validateRealtimeFeeds
function calls the _validateRealtimeFeeds
function. However, it lacks the payable
modifier:
function validateRealtimeFeeds(
DataStore dataStore,
address[] memory realtimeFeedTokens,
bytes[] memory realtimeFeedData
) external onlyController returns (OracleUtils.RealtimeFeedReport[] memory) {
return _validateRealtimeFeeds(dataStore, realtimeFeedTokens, realtimeFeedData);
}
It is recommended to implement the following changes:
Validate that the caller has attached sufficient ETH to cover the fee and refund any excess amount within _validateRealtimeFeeds
.
Add the payable
keyword to the validateRealtimeFeeds
function to allow callers to attach ETH for covering the fees.
SOLVED: The RFX team solved the issue when refactoring the codebase to v2.1.
// Low
In the _validateRealtimeFeeds
function of the Oracle contract, price feeds are queried for each token using the respective feed ID to generate real-time feed reports, of type OracleUtils.RealtimeFeedReport
, containing price details.
However, currently, the feedId
field within each generated report is not being set. Consequently, each report lacks the necessary feed ID information that was used to query the underlying price details. This omission can lead to ambiguity or incorrect interpretation of the price data retrieved.
Below is the definition of the OracleUtils.RealtimeFeedReport
structure:
struct RealtimeFeedReport {
// The feed ID the report has data for
bytes32 feedId;
// Min price
uint256 minPrice;
// Max price
uint256 maxPrice;
// Price exponent
int32 expo;
// Unix timestamp describing when the price was published
uint publishTime;
}
In the _validateRealtimeFeeds
function, the feedId
field is not set for each report:
OracleUtils.RealtimeFeedReport memory report;
if(baseFeedId != bytes32(0)) {
report = OracleUtils.getQuoteBaseReport(priceFeeds[0].price, priceFeeds[1].price);
} else {
report.publishTime = priceFeeds[0].price.publishTime;
report.expo = priceFeeds[0].price.expo;
report.minPrice = uint256(uint64(priceFeeds[0].price.price) - priceFeeds[0].price.conf);
report.maxPrice = uint256(uint64(priceFeeds[0].price.price) + priceFeeds[0].price.conf);
}
if (report.publishTime + maxPriceAge < Chain.currentTimestamp()) {
revert Errors.RealtimeMaxPriceAgeExceeded(token, report.publishTime, Chain.currentTimestamp());
}
reports[i] = report;
Consider setting each report's feedId
:
report.feedId = feedId;
reports[i] = report;
SOLVED: The RFX team solved the issue when refactoring the codebase to v2.1.
// Low
The _revokeRole
function in the RoleStore
contract does not remove roleKey
from the role
set when there are no more members (i.e.: roleMembers[roleKey].length()
is 0). As a consequence, the number of roles stored in the contract calculated in the getRoleCount
function could be distorted from the current value.
The _revokeRole
function does not remove roleKey
from role
set when there are no more members:
function _revokeRole(address account, bytes32 roleKey) internal {
roleMembers[roleKey].remove(account);
roleCache[account][roleKey] = false;
if (roleMembers[roleKey].length() == 0) {
if (roleKey == Role.ROLE_ADMIN) {
revert Errors.ThereMustBeAtLeastOneRoleAdmin();
}
if (roleKey == Role.TIMELOCK_MULTISIG) {
revert Errors.ThereMustBeAtLeastOneTimelockMultiSig();
}
}
}
It is recommended to remove keys from the role
set when there are no more members.
SOLVED: The RFX team solved the issue in the specified commit id.
// Low
The _autoTopUpSubaccount
function in the SubaccountRouter
contract verifies if the balance of wrapped native tokens for the account
is sufficient before further processing. However, this validation is carried out before capping the top-up amount
to the amount of native tokens used, which could result in some subaccounts not being correctly topped up.
The _autoTopUpSubaccount
function verifies the balance of wrapped native tokens for the account
before capping the top-up amount
:
function _autoTopUpSubaccount(address account, address subaccount, uint256 startingGas, uint256 executionFee) internal {
uint256 amount = SubaccountUtils.getSubaccountAutoTopUpAmount(dataStore, account, subaccount);
if (amount == 0) {
return;
}
IERC20 wnt = IERC20(dataStore.getAddress(Keys.WNT));
if (wnt.allowance(account, address(router)) < amount) { return; }
if (wnt.balanceOf(account) < amount) { return; }
// cap the top up amount to the amount of native tokens used
uint256 nativeTokensUsed = (startingGas - gasleft()) * tx.gasprice + executionFee;
if (nativeTokensUsed < amount) { amount = nativeTokensUsed; }
router.pluginTransfer(
address(wnt), // token
account, // account
address(this), // receiver
amount // amount
);
TokenUtils.withdrawAndSendNativeToken(
dataStore,
address(wnt),
subaccount,
amount
);
EventUtils.EventLogData memory eventData;
eventData.addressItems.initItems(2);
eventData.addressItems.setItem(0, "account", account);
eventData.addressItems.setItem(1, "subaccount", subaccount);
eventData.uintItems.initItems(1);
eventData.uintItems.setItem(0, "amount", amount);
eventEmitter.emitEventLog2(
"SubaccountAutoTopUp",
Cast.toBytes32(account),
Cast.toBytes32(subaccount),
eventData
);
}
It is recommended to verify the balance of wrapped native tokens for the account
after capping the top-up amount
.
SOLVED: The RFX team solved the issue in the specified commit id.
// Informational
When executing market orders, the processOrder
function within each order type manages order execution and returns event log data. This data can later be utilized within the afterOrderExecution
function inside the order’s callback contract.
However, when executing order increases via IncreaseOrderUtils.processOrder
, the function returns empty event log data, thereby preventing the callback from utilizing the execution results.
The IncreaseOrderUtils.processOrder
function returns an empty event log data after processing each order:
function processOrder(BaseOrderUtils.ExecuteOrderParams memory params) external returns (EventUtils.EventLogData memory) {
...
EventUtils.EventLogData memory eventData;
return eventData;
}
It is recommended to include the collateral token address and the corresponding increment amount used to increase the user’s position in the event log data. These parameters are returned from the SwapUtils.swap
call:
(address collateralToken, uint256 collateralIncrementAmount) = SwapUtils.swap(SwapUtils.SwapParams(
...
SOLVED: The RFX team has solved this issue by including the collateral token address and the corresponding increment amount used to increase the user’s position in the event log data.
// Informational
Some functions in the codebase do not include a zero address check for their parameters. If one of those parameters is mistakenly set to zero, it could affect the correct operation of the protocol. The affected functions are the following:
DataStore.setAddress
BaseOrderHandler.constructor
DepositHandler.constructor
FeeHandler.constructor
MarketFactory.constructor
OracleStore.constructor
OracleStore.addSigner
RoleModule.constructor
SubaccountRouter.constructor
It is recommended to add a zero address check in the functions mentioned above.
ACKNOWLEDGED: The RFX team acknowledged this issue.
// Informational
In the SwapUtils._swap
function, there is a risk that the declared cache
variable might not be utilized if one of the subsequent validations fails. This could lead to unnecessary gas consumption.
The cache
variable declaration might remain unused if one of the subsequent validations fails:
function _swap(SwapParams memory params, _SwapParams memory _params) internal returns (address, uint256) {
SwapCache memory cache;
if (_params.tokenIn != _params.market.longToken && _params.tokenIn != _params.market.shortToken) {
revert Errors.InvalidTokenIn(_params.tokenIn, _params.market.marketToken);
}
MarketUtils.validateSwapMarket(params.dataStore, _params.market);
cache.tokenOut = MarketUtils.getOppositeToken(_params.tokenIn, _params.market);
Consider declaring the cache
variable immediately after the MarketUtils.validateSwapMarket
check to ensure it is used in the intended context.
SOLVED: The RFX team has solved this issue by implementing the recommended change.
// Informational
Some functions in the codebase are duplicated (i.e.: they contain the same code logic), which could make it harder to apply future changes in the common logic of the functions and can even introduce potential bugs. The affected functions are the following:
DataStore.applyDeltaToUint
function has duplicated code with DataStore.incrementUint
.
DataStore.applyDeltaToInt
function has duplicated code with DataStore.incrementInt
.
This situation is not security-related, but mentioned in the report as part of the DRY (Don't Repeat Yourself) principle used as a best practice in software development to improve the maintainability of code during all phases of its lifecycle.
It is recommended to update the codebase to call only one version of the duplicated functions and remove the other ones to avoid potential mistakes.
SOLVED: The RFX team solved the issue in the specified commit id.
// Informational
In the ExecuteDepositUtils._executeDeposit
function, the deposit logic is segregated based on the price impact factor. The initial if
clause manages scenarios with a positive price impact, whereas the subsequent if
clause handles those with a negative price impact. Given that the first if
clause checks params.priceImpactUsd > 0
, it logically follows that the second if
clause checks params.priceImpactUsd < 0
. However, since the conditions are mutually exclusive, the second if
clause will never evaluate to true if the first condition is satisfied.
The ExecuteDepositUtils._executeDeposit
function contains an inefficient if
clause:
if (_params.priceImpactUsd > 0) {
...
}
if (_params.priceImpactUsd < 0) {
...
}
It is recommended to use the else if
construct as follows: else if (_params.priceImpactUsd < 0)
to avoid redundant checks for a negative price impact after confirming a positive one.
SOLVED: The RFX team has solved this issue by implementing the recommended change.
// Informational
Within the existing for
loops, the following optimization principles can be applied to reduce gas:
Increment loop counter in an unchecked block:
In Solidity, most for
loops use a uint256
counter variable that starts at 0 and increments by 1. Checking for over/underflow in these increments is unnecessary because the variable will exhaust gas long before reaching the maximum capacity of uint256
.
Use ++i instead of i++:
With i++
, the value of i
(its old value) is returned before incrementing i
to its new value. This results in two values being stored on the stack, regardless of whether you intend to use both. In contrast, ++i
evaluates the increment operation first, updating i
to its incremented value, and then returns this new value. This approach requires only one item to be stored on the stack.
Cache array lengths outside of loop:
When the length of an array is not cached outside of a loop, the Solidity compiler reads the length of the array during each iteration. For storage arrays, this leads to an additional SLOAD
operation, incurring 100 extra gas for each iteration except the first. For memory arrays, an extra MLOAD
operation is performed, adding 3 additional gas for each iteration except the first. Identifying loops where the length of a storage array is accessed in the loop condition without being modified inside the loop can highlight optimization opportunities.
The optimizations mentioned above can be implemented in the following cases:
contracts/fee/FeeHandler.sol#L38
contracts/oracle/OracleModule.sol#L56
contracts/oracle/Oracle.sol#L316, L407
contracts/swap/SwapUtils.sol#L136, L165
It is recommended to implement the adequate for
loop optimizations in the following way:
Change:
for (uint256 i; i < array.length; i++) {..}
To:
length = array.length
for (uint256 i; i < length;) {
...
unchecked{++i;}
}
ACKNOWLEDGED: The RFX team acknowledged this issue.
// Informational
Every Solidity file specifies in the header a version number of the format pragma solidity (^)0.8.*
. The caret ( ^
) before the version number implies an unlocked pragma, meaning that the compiler will use the specified version and above, hence the term "unlocked".
Contracts should be deployed using the same compiler version and flags that were used during their development and testing phases. Locking the pragma ensures consistency and prevents unintended deployment with a different pragma version. Using an outdated pragma version can introduce bugs that may adversely affect the contract system.
All contracts within the codebase use an unlocked pragma:
pragma solidity ^0.8.0;
Consider locking the pragma version in the smart contracts. It is not recommended to use a floating pragma in production.
For example: pragma solidity 0.8.21;
ACKNOWLEDGED: The RFX team acknowledged this issue.
// Informational
Within the codebase, the following components remain unused:
Errors.CouldNotSendNativeToken
error message
Errors.HasRealtimeFeedId
error message
Errors.InvalidRealtimeFeedId
error message
Errors.InvalidRealtimeBidAsk
error message
Errors.InvalidRealtimeBlockHash
error message
Errors.InvalidRealtimePrices
error message
Errors.MaxPriceAgeExceeded
error message
Errors.MinOracleSigners
error message
Errors.MaxOracleSigners
error message
Errors.BlockNumbersNotSorted
error message
Errors.MinPricesNotSorted error
message
Errors.MaxPricesNotSorted
error message
Errors.InvalidFeedPrice
error message
Errors.PriceFeedNotUpdated
error message
Errors.MaxSignerIndex
error message
Errors.InvalidOraclePrice
error message
Errors.InvalidSignerMinMaxPrice
error message
Errors.InvalidMedianMinMaxPrice
error message
Errors.EmptyPriceFeed
error message
Errors.UnsupportedOracleBlockNumberType
error message
oracleParams
argument in the BaseOrderHandler._getExecuteOrderParams
function
Oracle.ValidatedPrice
struct
Oracle.SetPricesCache
struct
Oracle.getStablePrice
function
Oracle.getPriceFeedMultiplier
function
Oracle._getSalt
function
These elements are present within the codebase but are not currently utilized.
It is recommended to remove unused components in the codebase to save gas.
PARTIALLY SOLVED: The RFX team solved most of the issue when refactoring the codebase to v2.1. The only remaining unused component is the Errors.BlockNumbersNotSorted
error message.
// Informational
The following issues were identified in the NatSpec documentation:
OracleUtils.SetPricesParams struct:
The compactedOracleBlockNumbers
parameter is outdated and no longer exists.
The realtimeFeedTokens
and realtimeFeedData
parameters lack NatSpec documentation.
OracleUtils.validateSigner function:
The minOracleBlockNumber
and maxOracleBlockNumber
parameters are no longer used but are still documented.
ExecuteDepositUtils.ExecuteDepositParams struct:
The oracleBlockNumbers
parameter is deprecated and requires removal from NatSpec documentation.
No NatSpec documentation exists for the depositVault
parameter.
SwapUtils.SwapCache struct:
The priceImpactUsd
and priceImpactAmount
parameters are undocumented.
Withdrawal.Addresses struct:
The longTokenSwapPath
and shortTokenSwapPath
parameters lack NatSpec documentation.
Withdrawal.Flags struct:
The shouldUnwrapNativeToken
parameter's NatSpec comment is incomplete and needs revision:"whether to unwrap the native token when"
.
ExecuteWithdrawalParams struct:
The minOracleBlockNumbers
and maxOracleBlockNumbers
parameters are outdated and should be removed from NatSpec documentation.
ExecuteWithdrawalUtils.executeWithdrawal function:
The withdrawal
parameter lacks NatSpec documentation.
Order.Numbers struct:
The decreasePositionSwapType
parameter is not documented within the NatSpec comments.
Market.Props struct:
The data
parameter is documented in NatSpec comments but does not exist and should be removed.
Consider updating the NatSpec comments by removing outdated parameters, documenting the missing ones, and completing unfinished comments.
PARTIALLY SOLVED: The RFX team has partially solved this issue when refactoring the codebase to v2.1. However, there are still some NatSpec comments that are either missing or incorrect:
SwapUtils.SwapCache struct:
The priceImpactUsd
and priceImpactAmount
parameters are undocumented.
Withdrawal.Addresses struct:
The longTokenSwapPath
and shortTokenSwapPath
parameters lack NatSpec documentation.
Withdrawal.Flags struct:
The shouldUnwrapNativeToken
parameter's NatSpec comment is incomplete and needs revision:"whether to unwrap the native token when"
.
ExecuteWithdrawalUtils.executeWithdrawal function:
The withdrawal
parameter lacks NatSpec documentation.
Order.Numbers struct:
The decreasePositionSwapType
parameter is not documented within the NatSpec comments.
Market.Props struct:
The data
parameter is documented in NatSpec comments but does not exist and should be removed.
// Informational
Within the NatSpec documentation for the Oracle.getPriceFeedMultiplier
function, the word "multiplier" was misspelled as "multipler":
// @return the price feed multipler
It is recommended to fix the present typo.
SOLVED: The RFX team solved the issue when refactoring the codebase to v2.1.
// Informational
Incorporating comments into the codebase is essential for elucidating key aspects and facilitating comprehension of the developer's intentions by others. Nevertheless, several errors were identified within these comments:
In the OracleUtils
contract, the RealtimeFeedReport
structure’s comments incorrectly referenced bid
and ask
parameters, which have since been deprecated and renamed to minPrice
and maxPrice
respectively.
// bid: min price, highest buy price
// ask: max price, lowest sell price
struct RealtimeFeedReport {
// The feed ID the report has data for
bytes32 feedId;
// Min price
uint256 minPrice;
// Max price
uint256 maxPrice;
// Price exponent
int32 expo;
// Unix timestamp describing when the price was published
uint publishTime;
}
In the Oracle
contract, it was stated that the setPrices
function checked the number of signers and then called _setPrices
and _setPricesFromPriceFeeds
to set tokens prices. However, this logic no longer applies in the current implementation.
// bits contain the index of each signer in the oracleStore. The function checks that the number
// of signers is greater than or equal to the minimum number of signers required, and that
// the signer indices are unique and within the maximum signer index. The function then calls
// _setPrices and _setPricesFromPriceFeeds to set the prices of the tokens.
Below is the implementation of the setPrices
function:
function setPrices(
DataStore dataStore,
EventEmitter eventEmitter,
OracleUtils.SetPricesParams memory params
) external payable onlyController {
if (tokensWithPrices.length() != 0) {
revert Errors.NonEmptyTokensWithPrices(tokensWithPrices.length());
}
_setPricesFromRealtimeFeeds(dataStore, eventEmitter, params);
}
Furthermore, examples within the comments of the Oracle.setPrices
function contained incorrect exponent values, leading to inaccurate prices:
In the comment below, (2 ^ 64)
should have been (2 ^ 32)
instead:
// USDC prices maximum value: `(2 ^ 64) / (10 ^ 6) => 4,294,967,296 / (10 ^ 6) => 4294.967296`.
In the comment below, (10 ^ 3)
should have been (10 ^ 4)
instead:
// Price would be stored as `1 * (10 ^ -26) * (10 ^ 30) => 1 * (10 ^ 3)`.
In the comment below, (2 ^ 64)
should have been (2 ^ 32)
instead:
// DG prices maximum value: `(2 ^ 64) / (10 ^ 11) => 4,294,967,296 / (10 ^ 11) => 0.04294967296`.
It is recommended to remove deprecated comments and correct any comments that contain errors.
SOLVED: The RFX team solved the issue when refactoring the codebase to v2.1.
// Informational
Within the codebase, the following unused imports were identified:
oracle/OracleUtils.sol:
import "../utils/Printer.sol";
oracle/Oracle.sol:
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./IPriceFeed.sol";
import "./IRealtimeFeedVerifier.sol";
import "../utils/Bits.sol";
import "../utils/Array.sol";
import "hardhat/console.sol";
utils/Precision.sol:
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
deposit/ExecuteDepositUtils.sol:
import "../error/ErrorUtils.sol";
swap/SwapUtils.sol:
import "../token/TokenUtils.sol";
withdrawal/ExecuteWithdrawalUtils.sol:
import "../adl/AdlUtils.sol";
import "../nonce/NonceUtils.sol";
import "../oracle/OracleUtils.sol";
import "../utils/AccountUtils.sol";
order/SwapOrderUtils.sol:
import "../order/OrderStoreUtils.sol";
order/DecreaseOrderUtils.sol:
import "../order/OrderStoreUtils.sol";
order/IncreaseOrderUtils.sol:
import "../order/OrderStoreUtils.sol";
import "../callback/CallbackUtils.sol";
Consider removing the unused import statements.
PARTIALLY SOLVED: The RFX team has partially solved this issue by removing most of the unused import statements. However, there is still one unused import that remains in the order/DecreaseOrderUtils.sol contract:
import "../order/OrderStoreUtils.sol";
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed