Reach out for an audit or to learn more about Macro
or Message on Telegram

Infinex A-4

Security Audit

June 19, 2024

Version 1.0.0

Presented by 0xMacro

Table of Contents

Introduction

This document includes the results of the security audit for Infinex's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from May 9 to May 22 2024.

The purpose of this audit is to review the source code of certain Infinex Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.

Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.

Overall Assessment

The following is an aggregation of issues found by the Macro Audit team:

Severity Count Acknowledged Won't Do Addressed
High 2 - - 2
Medium 3 - - 3
Low 2 - - 2
Code Quality 8 - 2 6

Infinex was quick to respond to these issues.

Specification

Our understanding of the specification was based on the following sources:

Trust Model, Assumptions, and Accepted Risks (TMAAR)

Withdrawals

GovernancePoints

CurveApp

AppAccounts

Source Code

The following source code was reviewed during the audit:


Specifically, we audited the following contracts for
Account App within commit 14218c87930338f4cd58e2b89f4a52c1efde1c09:

Source Code SHA256
./src/accounts/AccountFactory.sol

f152119fa2802a63f6d80816cc4f43c00902c7b2e49ba831237a595395dd6357

./src/accounts/modules/AccountUtilsModule.sol

1209e686205d878bdc37a47ba58c150dfef41591568d46f2a013ee49e137a718

./src/accounts/modules/AppModule.sol

a8a0b5d5e905ef67802509f34dc65105ece46ec273880fc62eb2a2c29dd01e62

./src/accounts/modules/BaseModule.sol

b5eea1cca201bc7f713797a21d0afaf4b98f7c9c6351f8ba6ea6db6ca5cce9fc

./src/accounts/modules/BridgingModule.sol

e1e02bf5dc9fad21689e9a67e75f4fdefd65c77bb830e406e20db162d83146d7

./src/accounts/modules/RecoveryModule.sol

fecb1e896f5e2b55adf6a5656aa4255d0ad63d3e4b8ad7577edb61b24a19c76a

./src/accounts/modules/WithdrawModule.sol

2727580e11626c438cb9a088878b52c7443773d5f89d52c68d21a4426a4a19b6

./src/accounts/storage/Account.sol

b324ad05c22dc05d086b2047c84da78cd4fd13277b6b4f76cfecf7c25072d691

./src/accounts/storage/App.sol

1cb535366cf674da1c44ceb713249ca63315ddf5e928ec959ed527d0921ba9bf

./src/accounts/storage/Bridge.sol

cc8f253b656a46d605b53e1e19664448d4560d412032913383641d7861abe188

./src/accounts/storage/EIP712.sol

00922e211d9cb73dbfb374910cf05cc52b8c11618e3d903d730e9c7d4a7a2e1d

./src/accounts/storage/Recovery.sol

c68ccdd0fb870aa3d7a7932004d7a9c4ddf07a8020ccee35d19cc499c7922296

./src/accounts/storage/SecurityKeys.sol

e0a356558af9c23344a6db8433c35fe440ceb91112f57bac28479befacf74f1e

./src/accounts/storage/Withdrawal.sol

be22a9a04acba1c3f05f42c7a7b370cfa72e4ddac133ff76eeab7fa7a00a348b

./src/accounts/utils/AccountConstants.sol

e6f4420118e4b841d54a2e31f65af8905c86f01068bba4270efdca2f867c0e98

./src/accounts/utils/RequestTypes.sol

47f7d9e0aef9cb95b439fdff5d01c232528650d5f62d29c86f4fcb67135f8c42

./src/accounts/utils/SecurityModifiers.sol

2e75ab375d273880ec4349a1a29c2574121a2a83b45718589ea00cac971c63dc

./src/accounts/utils/WithdrawUtil.sol

1bd4c68e2156418d96113544d581d2421472744ce7d0c26b8d38aa0a11a938cd

./src/apps/AppAccountFactory.sol

e4ddc89589b0c217a3546ae9228220d7166c81603b08d2c3cbb1337047b40e9d

./src/apps/AppRegistry.sol

6274e58ac2ad448a032a4ba4eaefd0cc819e58930b668ddcd624856d99261e00

./src/apps/base/AppAccountBase.sol

e75c520c5c7535653e3c37c31b6e485d37264a3dcc536e05bbf5c57096c88082

./src/apps/base/AppBase.sol

7f6021aeeea19488421fc1fba9d8fd074b61f1f9ed26f9f2ffb1a5209e9b1dcb

./src/apps/base/AppBeaconBase.sol

b45c7e576dce18bbbd27a2efef6fa9c0844699b63d6575f700950acf7f057aa8

./src/apps/base/AppERC2771Context.sol

dd45c2f083064d67444dea1e8bd1703e4e37dfb9f8e679f19b3b8863458e56aa

./src/apps/base/AppSecurityModifiers.sol

f080cb7d93f1197150a490292cdc52765c35cb61fc195d2379f8b7eebf1e5f5b

Curve Integration App within commit 09847d2b2af18bd9cf38094a0eb060b2aa3c15f4:

Source Code SHA256
./src/apps/curve/CurveAppError.sol

ef6ec02abefa31e36d3747118c3ecc730144f763a03076852d60dda36c6dbd27

./src/apps/curve/CurveStableSwapApp.sol

c6a920d2d0fe4f3763c34dc37affd273bf96ed410a3ba23f2fc37773f9e67230

./src/apps/curve/CurveStableSwapAppBeacon.sol

30ad6beebc3aff724c946ea30fb73b91506a962232c67a4452825eb971f49a9c

Governance Points update within commit 1b112c1f418d0ebbdd27fa955577fe16211f9621:

Source Code SHA256
./src/governance/GovernancePoints.sol

54fc8812732a6b4d08eeda5bcf6cfa0bc7622656e5d6b76f19d80c36d4b4964b

./src/governance/GovernancePointsStorage.sol

7722f934a5fcde4c1cc46f3b22dcf95fc085da07842659311642d56c35bba4db

Note: Currently the referenced repository is private, but there are plans to make it public in the future.

Issue Descriptions and Recommendations

Click on an issue to jump to it, or scroll down to see them all.

Security Level Reference

We quantify issues in three parts:

  1. The high/medium/low/spec-breaking impact of the issue:
    • How bad things can get (for a vulnerability)
    • The significance of an improvement (for a code quality issue)
    • The amount of gas saved (for a gas optimization)
  2. The high/medium/low likelihood of the issue:
    • How likely is the issue to occur (for a vulnerability)
  3. The overall critical/high/medium/low severity of the issue.

This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:

Severity Description
(C-x)
Critical

We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost.

(H-x)
High

We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec.

(M-x)
Medium

We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner.

(L-x)
Low

The risk is small, unlikely, or may not relevant to the project in a meaningful way.

Whether or not the project wants to develop a fix is up to the goals and needs of the project.

(Q-x)
Code Quality

The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design.

(I-x)
Informational

Warnings and things to keep in mind when operating the protocol. No immediate action required.

(G-x)
Gas Optimizations

The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it.

Issue Details

H-1

Recover methods do not have sufficient slippage protection

Topic
Frontrunning
Status
Impact
High
Likelihood
High

In CurveStableSwapApp.recoverTokenToUSDC, the provided _fromToken is swapped to USDC by using the following exchange call:

// swap to USDC, take the current rate (dy) as the min amount
uint256 receivedAmount = ICurveStableSwapNG(poolData.pool).exchange(
    poolData.fromTokenIndex,
    poolData.toTokenIndex,
    balance,
    ICurveStableSwapNG(poolData.pool).get_dy(poolData.fromTokenIndex, poolData.toTokenIndex, balance)  // @audit the exchange will happen regardless how much the minimum amount is (get_dy)
);

Reference: CurveStableSwapApp.sol#L154-L159

For slippage protection, the last parameter specifies the minimum amount of USDC that must be returned, otherwise the function will revert. This minimum amount is obtained by calling the get_dy call, which returns the amount of USDC tokens using the current exchange rate. The issue here is that for an imbalanced pool, this received amount can be very low, but the exchange call would still succeed.

Let us consider a Curve pool with 100 DAI and 100 USDC (1:1 ratio).

  1. A user wants to recover his token to USDC by exchanging his DAI balance. Meaning if the user has 18 DAI deposited, he would receive 18 USDC assuming the pool is balanced.
  2. Transaction 1: An attacker front-runs the transaction and swaps his 80 DAI to receive 80 USDC. The pool is now in an imbalanced state with a 1:9 ratio:
    • 20 USDC.
    • 180 DAI.
  3. Transaction 2: The user's transaction is executed, and recoverTokenToUSDC is called.
    • As the pool is in an imbalanced state with a 1:9 ratio, calling the exchange function will cause the user to give 18 DAI to receive only 2 USDC back (instead of 18 USDC as when the pool is balanced).
  4. Transaction 3: The attacker backruns the transaction and makes profits by selling USDC in return for DAI.

The above scenario shows that the user suffers from significant loss due to the lack of sufficient slippage protection. In addition, proper slippage protection is also missing for recoverUSDCFromLP and recoverTokenFromLP functions.

Remediation to Consider

To eliminate the risk of such an attack, the minimum amount should be specified by the caller instead of using the amount returned by get_dy. Correspondingly, consider adding a minimum amount value provided by the caller for recoverUSDCFromLP and recoverTokenFromLP functions.

H-2

Operation keys can transfer tokens to an arbitrary address

Topic
Trust Assumption
Status
Impact
High
Likelihood
Medium

In CurveStableSwapApp.sol, authorized operations parties can perform specific interactions with Curve such as exchange, add, and remove liquidity with stable swap pools. For example, an operation key can addLiquidity() to a Curve pool specified in _pool address:

function addLiquidity(address _pool, address[] calldata _tokens, uint256[] calldata _amounts, uint256 _minLPAmount)
    external
    nonReentrant
    requiresAuthorizedOperationsParty
{
    // approve the pool to add the tokens as liquidity
    for (uint256 i = 0; i < _tokens.length; i++) {
        IERC20(_tokens[i]).approve(_pool, _amounts[i]);
    }
    // provide the liquidity
    uint256 lpAmount = ICurveStableSwapNG(_pool).add_liquidity(_amounts, _minLPAmount);
    emit LiquidityAdded(_pool, _amounts, lpAmount);

    // Curve only allows 0 to amount approvals, so reset approval to 0
    for (uint256 i = 0; i < _tokens.length; i++) {
        IERC20(_tokens[i]).approve(_pool, 0);
    }
} 

Reference: CurveStableSwapApp.sol#L71-L88

However, there are no checks to ensure the address passed as _pool is a valid Curve contract. Malicious Operation Keys could deploy a contract that implements the add_liquidity() function and use it to drain all token balances. This breaks the trust assumption on Operation Keys and potentially allows any logic to be executed with arbitrary token approvals.

In addition, this can also be exploited from exchange(), removeSingleTokenLiquidity() and removeSpecificLiquidity() functions.

Remediations to Consider

Consider checking whether the pool address given is a legitimate Curve pool by verifying it through the curveStableswapFactoryNG in the beacon.

M-1

Potential storage collision between Account and App

Topic
Storage Collision
Status
Impact
High
Likelihood
Low

Each account module listed under /accounts/modules has its own storage space that is defined in the corresponding library in /storage. This is necessary to avoid any storage collisions between different modules. However, for the new AppModule, this assumption doesn’t hold. The AppModule uses the storage slot defined in /storage/App.sol:

bytes32 s = keccak256(abi.encode("io.infinex.AccountStorage"));

Reference: App.sol#L26

As seen above, it uses “io.infinex.AccountStorage” for the storage slot, which is the same slot defined in /storage/Account.sol used by the AccountUtilsModule.

For the current implementation, this is not necessarily an issue as the data used in App.sol consists only of a mapping, which stores the mappings data in different storage slots anyway. However, this could become a serious issue if additional elements are added to the App’s Data struct in future releases.

Remediation to Consider

Use a separate storage slot for the AppModule, i.e. “io.infinex.App”.

M-2

Deployed Apps can not be deprecated

Topic
Use Case
Status
Impact
High
Likelihood
Low

In AppModule, once an App is deployed, there is no way to remove it from the allowed appAccountsToAppBeacons mapping. This allows an authorized operation party to send assets even if this app is no longer supported or intended to be used.

If an app implementation is broken, assets can still be sent mistakenly and lost.

Remediations to Consider:

Consider adding a function to remove existing apps as a Sudo Key.

M-3

Withdrawal delay is not working as intended

Topic
Protocol Design
Status
Impact
Low
Likelihood
High

WithdrawalModuleV1 adds a mandatory delay for adding new withdrawal addresses. It is initially set to 24 hours but will be allowed to change to a higher value in future implementations.

As we can see in Withdrawal._setAllowlistedWithdrawalAddress(), it sets the corresponding allowlistedWithdrawalAddressesValidFrom to block.timestamp + data.allowlistDelay:

if (_status) {
    uint256 validFrom = block.timestamp + data.allowlistDelay;
emit AllowlistedWithdrawAddressSetWithDelay(_allowlistedWithdrawalAddress, validFrom);
    data.allowlistedWithdrawalAddressesValidFrom[_allowlistedWithdrawalAddress] = validFrom;

Reference: Withdrawal.sol#L69-L72

However, the issue arises in the _isAllowlistedWithdrawalAddress() internal function used to verify if an allowlisted address is a valid withdrawal address, as the check on L45 compares the mapping value with block.timestamp + 24 hours. As a result, there is no 24 hours delay as intended, but instead withdrawals can happen immediately after the withdrawal address is set.

function _isAllowlistedWithdrawalAddress(address _withdrawalAddress) internal view returns (bool) {
    Data storage data = getStorage();
    if (data.allowlistedWithdrawalAddressesValidFrom[_withdrawalAddress] == 0) {
        return false;
    }
    return data.allowlistedWithdrawalAddressesValidFrom[_withdrawalAddress] < block.timestamp + 24 hours;
}

Reference: Withdrawal.sol#L40-L46

Remediations to Consider

Consider removing the additional 24 hours on L45 and only using block.timestamp in _isAllowlistedWithdrawalAddress().

L-1

recoverTokenToUSDC can revert with out of balance depending on the pool exchange rate

Topic
Protocol Design
Status
Impact
Low
Likelihood
High

In CruveStableSwappApp contract, the function recoverTokenToUSDC() first fetches the _fromToken balance, then exchanges it to USDC, and finally transfers the USDC balance to the main account:

    function recoverTokenToUSDC(address _fromToken) public nonReentrant requiresAuthorizedRecoveryParty {
        uint256 balance = IERC20(_fromToken).balanceOf(address(this));
        ICurveAppBeacon appBeacon = ICurveAppBeacon(AppBase._getAppBeacon());
        address USDC = appBeacon.USDC();
        ICurveAppBeacon.PoolData memory poolData = appBeacon.getPoolDatafromTokens(_fromToken, USDC, balance);
        IERC20(_fromToken).approve(poolData.pool, balance);
        // swap to USDC, take the current rate (dy) as the min amount
        uint256 receivedAmount = ICurveStableSwapNG(poolData.pool).exchange(
            poolData.fromTokenIndex,
            poolData.toTokenIndex,
            balance,
            ICurveStableSwapNG(poolData.pool).get_dy(poolData.fromTokenIndex, poolData.toTokenIndex, balance)
        );
        emit TokensExchanged(poolData.pool, poolData.fromTokenIndex, poolData.toTokenIndex, balance, receivedAmount);
        // Curve only allows 0 to amount approvals, so reset approval to 0
        IERC20(USDC).approve(poolData.pool, 0);

        emit TokensRecoveredToMainAccount(USDC, receivedAmount);
        // transfer USDC to mainAccount
        _transferToMainAccountERC20(USDC, balance);
    }

Reference: CurveStableSwapApp.sol#L147-L167

However, the amount used in _transferToMainAccountERC20() is the initial balance of the _fromToken; if the exchange rate returned differs from a 1:1 conversion, this function will revert, preventing a recovery party from executing this function properly.

Note: This function used the receivedAmount variable but was changed in https://github.com/infinex-xyz/infinex-contracts/commit/c9133b078888273445c9070bcfe8b1d8a02b0980.

Remediations to Consider:

Consider using the receivedAmount variable returned from the exchange() call or the USDC balance as other recovery functions do.

L-2

Valid withdrawal address can be overridden and reset

Topic
Use Case
Status
Impact
Low
Likelihood
Low

Once a sudo key sets an address using setAllowlistedWithdrawalAddress() and the corresponding allowlistDelay has elapsed, this address can be used as a valid target for withdrawing assets within an account. However, if setAllowlistedWithdrawalAddress() is called with the same address, the delay period resets. This action renders the address invalid for withdrawals until the delay period has passed again.

Remediations to Consider

Consider checking whether the corresponding value of the _allowlistedWithdrawalAddress already has a value different from 0 and revert if so.

Q-1

App Accounts can receive tokens but may not have the ability to withdraw them

Topic
Locked funds
Status
Quality Impact
High

The AppModule defines functions for transferring tokens to underlying app accounts, specifically:

  • transferEthToApp
  • transferERC20TokenToApp
  • transferERC721TokenToApp

However, the AppAccountBase, used by the app accounts, doesn’t implement any function for withdrawing tokens or transferring them back to the main account. This could potentially result in tokens becoming locked in the app accounts.

Remediation to Consider

Add appropriate functions to AppAccountBase that allow users to either withdraw tokens or to transfer them back to the main account.

Q-2

Accounts can hold ERC721 but no ERC1155 tokens

Topic
Use Case
Status
Quality Impact
Medium

Both the main account and app accounts inherit from ERC721Holder, allowing them to receive ERC721 tokens. However, currently, the accounts don’t support receiving ERC1155 tokens.

Remediation to Consider

Support receiving ERC1155 tokens by inheriting from ERC1155Holder in addition to ERC721Holder for both the BaseModule and AppAccountBase.

Q-3

App Accounts can not update to a new beacon

Topic
Protocol Design
Status
Quality Impact
Medium

AppBeacon contract stores the latestAppBeacon in its storage, initially as their own address, but can be updated by the beacon owner. However, there are no functions that allow an App to update the appBeacon address, this parameter is only set during initialization. Consider adding functions in the AppAccountBase to fetch and update the beacon address from the current beacon.

Q-4

Valid beacon checks can be included in the AppAccountFactory logic

Topic
Protocol Design
Status
Wont Do
Quality Impact
Medium

Currently, the AppRegistry is used to check whether the passed appBeacon is valid directly in the AppModule contract used in the Main Account. This allows external actors to directly call the AppAccountFactory and deploy Apps. Although this does not imply a real impact, it could create false, corrupted contracts using Infinex's legitimate factory. Consider having the AppRegistry checks in the AppAccountFactory.

Response by Infinex

This is intended in order to reduce coupling between the AppAccountFactory and AppRegistry

Q-5

Apps native transfers could revert in future Account upgrades

Topic
Best Practice
Status
Quality Impact
Medium

AppAccountBase._transferToMainAccountEther() function uses the .transfer() built-in method to transfer native assets to the main account address. This hardcodes 2300 gas to the call. Although, with the current proxy fallback() and implementation receive(), this function does not revert, consider using a low-level call to prevent this from changing in the future if new logic is added.

Q-6

Curve App executes redundant approvals

Topic
Protocol Design
Status
Quality Impact
Medium

Most function in this App implementation perform a similar logic scheme:

  1. approve() a specific amount or entire balance to the pool.
  2. Interact with Curve pools.
  3. Reset approval by calling approve() with a zero amount.

Although this does not pose any security issue, there are a couple of consistency issues with the codebase and how allowances should work:

  • Since an exact amount is used in approve() and functions in the pool transfer this same amount, the allowance is reset to zero without the need to change it back, slippage and other exchange conditions are checked after the assets are transferred into the pool and therefore there is no real need to reset allowances to zero. This applies to exchange() and addLiquidity() functions.
  • For removing liquidity, functions _removeSingleTokenLiquidity() and _removeSpecificLiquidity() interact with NG Curve pools through remove_liquidity_one_coin() and remove_liquidity_imbalance() both functions internally call _burnFrom to use the sender’s specified amount, since this function does not need or use allowance, the contract is approving the pool tokens unnecessarily potentially allowing this function to burn and spend the amount, rendering a double of the desired amount. Note that this is currently not possible with Curve pools but the allowance is unnecessarily approved.
  • Specifically in recoverTokenToUSDC() the logic first calls approve using _fromToken, but resets the approval on USDC token to zero afterward. Even if the exchange() is used to get USDC tokens, USDC pool allowance is never changed in this function logic and therefore it’s not needed to be set to zero.

Lastly, NG pools do not have any zero allowance requirement to execute approvals, as it can be seen in the CurveStableSwapNG contract.

Consider removing unnecessary approvals with zero value and approvals for removing liquidity functions.

Q-7

Duplicated check can be implemented in shared function

Topic
Duplicate Code
Status
Wont Do
Quality Impact
Low

All withdrawal functions ensure the withdrawalAddress input parameter is different from address(0) in their corresponding withdrawal asset type internal function. This check could be moved to the _validateWithdrawalAddress() function to avoid duplicated code.

Response by Infinex

Currently we only validate whether it is an allowlisted address, which will also cover 0 address (will always be 0), in the next feature, there will be functions that can withdraw to non-allow listed addresses, in which case the 0 check in those functions are important

Q-8

No support for selecting a specific pool when multiple pools are available for a token pair

Topic
Protocol Design
Status
Quality Impact
High

In CurveStableSwapApp, recoverTokenToUSDC and recoverTokenFromLP use the app beacon’s getPoolDatafromTokens to retrieve the pools address via find_pool_for_coins:

poolData.pool = ICurveStableSwapFactoryNG(curveStableswapFactoryNG).find_pool_for_coins(_fromToken, _toToken);

Reference: CurveStableSwapAppBeacon.sol#L60

The function find_pool_for_coins, when called without a specified index, always returns the pool's address stored at index 0. However, there may be multiple pools available for the specified token pair that offer better exchange rates.

Remediation to Consider

Consider adding an index parameter to getPoolDatafromTokens and passing it along to find_pool_for_coins. This would allow users to recover from a specific pool.

Disclaimer

Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.

Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.

The scope of this report and review is limited to a review of only the code presented by the Infinex team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. Specifically, for the avoidance of doubt, this report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.