Halborn Logo

Primex Contracts - Primex


Prepared by:

Halborn Logo

HALBORN

Last Updated 11/13/2024

Date of Engagement by: June 19th, 2023 - August 25th, 2023

Summary

100% of all REPORTED Findings have been addressed

All findings

17

Critical

0

High

0

Medium

4

Low

3

Informational

10


1. INTRODUCTION

Primex engaged Halborn to conduct a security assessment on their smart contracts beginning on 2023-06-19 and ending on 2023-08-25. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. ASSESSMENT SUMMARY

The team at Halborn was provided about two months for the engagement and assigned a full-time security engineer to verify the security of the smart contracts. 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 assessment 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 Primex team.

**REMEDIATION BRANCH/COMMIT IDs : **

**LATEST REPOSITORY/COMMIT ID : **

The latest commit checked on the newly created repository is as follows:

3. TEST APPROACH & 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 code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:

    • 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.

    • Manual testing by custom scripts.

    • Scanning of solidity files for vulnerabilities, security hot-spots or bugs. (MythX)

    • Static Analysis of security for scoped contract, and imported functions. (Slither)

    • Testnet environment. (Foundry)

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: primex_contracts
(b) Assessed Commit ID: f809cc0
(c) Items in scope:
    Out-of-Scope:
    Out-of-Scope: New features/implementations after the remediation commit IDs.

    6. Assessment Summary & Findings Overview

    Critical

    0

    High

    0

    Medium

    4

    Low

    3

    Informational

    10

    Security analysisRisk levelRemediation Date
    NON-STANDARD ERC20 TOKENS WILL REVERTMediumSolved - 09/15/2023
    RELAX STRICT CONDITIONS ON SWAPS WITHOUT DEBTMediumSolved - 09/15/2023
    batchDecreaseTradersDebt AND decreaseTraderDebt ACCOUNT PERMANENT LOSS IN A DIFFERENT WAYMediumSolved - 09/15/2023
    CHAINLINK latestRoundData MIGHT RETURN INCORRECT RESULTSMediumRisk Accepted - 08/25/2023
    IMPLEMENTATION CONTRACTS CAN BE INITIALIZEDLowSolved - 09/15/2023
    INCOMPATIBILITY WITH REBASING/DEFLATIONARY/INFLATIONARY TOKENSLowRisk Accepted - 08/25/2023
    IF FEE IS PAID ON PMX CONTROL MSG.VALUE IS ZEROLowSolved - 09/15/2023
    CONTROL REVERT IF BUCKET DOES NOT HAVE BALANCE FOR BURNING ALL PTOKENInformationalSolved - 09/15/2023
    EMIT EVENT ON updateIndexesInformationalSolved - 09/15/2023
    SOLIDITY VERSION 0.8.20 MAY NOT WORK ON OTHER CHAINS DUE TO PUSH0InformationalSolved - 09/15/2023
    USE SHIFT RIGHT/LEFT INSTEAD OF DIVISION MULTIPLICATION IF POSSIBLEInformationalAcknowledged - 09/15/2023
    INITIALIZED VARIABLE TO DEFAULT VALUEInformationalSolved - 09/15/2023
    CACHE ARRAY LENGTH OUTSIDE OF LOOPInformationalAcknowledged - 05/15/2023
    USE ++i INSTEAD OF i++ ON FOR LOOPSInformationalAcknowledged - 09/15/2023
    USE CUSTOM ERRORSInformationalSolved - 09/15/2023
    USING BOOLS FOR STORAGE INCURS OVERHEADInformationalAcknowledged - 09/15/2023
    FLOATING PRAGMAInformationalSolved - 09/15/2023

    7. Findings & Tech Details

    7.1 NON-STANDARD ERC20 TOKENS WILL REVERT

    // Medium

    Description

    The library TokenTransfersLibrary.sol contains the function to perform ERC20 tokens transfers in the protocol. However, this library uses the interface of IERC20 from OpenZeppelin which enforces the return value on transfer.

    This pattern is not followed by all ERC20 tokens, as for example USDT. If attempting to transfer these tokens, the contract will revert, preventing the transaction to be executed.

    Code Location

    TokenTransfersLibrary.sol#L12-L19

    TokenTransfersLibrary.sol

    function doTransferFromTo(address token, address from, address to, uint256 amount) public returns (uint256) {
        uint256 balanceBefore = IERC20(token).balanceOf(to);
        // The returned value is checked in the assembly code below.
        // Arbitrary `from` should be checked at a higher level. The library function cannot be called by the user.
        // slither-disable-next-line unchecked-transfer arbitrary-send-erc20
        IERC20(token).transferFrom(from, to, amount);
    
        bool success;
    

    TokenTransfersLibrary.sol#L46-L51

    TokenTransfersLibrary.sol

    function doTransferOut(address token, address to, uint256 amount) public {
        // The returned value is checked in the assembly code below.
        // slither-disable-next-line unchecked-transfer
        IERC20(token).transfer(to, amount);
    
        bool success;
    
    BVSS
    Recommendation

    Consider using a non-strict interface, as compound does, to transfer ERC20 tokens.

    Remediation

    SOLVED: The Primex team solved the issue by using a non-strict interface.

    Commit ID: 88d33deeebf9c169d21e333ef871c518b10e0b33

    7.2 RELAX STRICT CONDITIONS ON SWAPS WITHOUT DEBT

    // Medium

    Description

    The multiSwap function from the PrimexPricingLibrary.sol contract executes the token swap across the specified DEX and paths for opening and closing orders. However, when executing an order without leverage, the protocol sets the tolerable limit to zero, requiring for the swap to return an exact price given the oracle current prices.

    This results in most of sport orders reverting, as the retrieved tokens are normally less than the strict oracle prices exchange.

    Code Location

    PrimexPricingLibrary.sol#L353-L359

    PrimexPricingLibrary.sol

    if (_needOracleTolerableLimitCheck) {
        _require(
            vars.balance >=
                getOracleAmountsOut(_params.tokenA, _params.tokenB, _params.amountTokenA, _priceOracle).wmul(
                    WadRayMath.WAD - _maximumOracleTolerableLimit
                ),
            Errors.DIFFERENT_PRICE_DEX_AND_ORACLE.selector
        );
    
    BVSS
    Recommendation

    Consider allowing the _maximumOracleTolerableLimit for spot orders or avoid executing this code section as the minimum amount out is also checked and specified by the user.

    Remediation

    SOLVED: The Primex team solved the issue by turning off oracle checks for manual closing of spot positions and kept oracle tolerable limit check obligatory for limit orders with non-zero tolerance.

    Commit ID: 3532cdb3b5f7e59e031804f5d125ac957692cdf9

    7.3 batchDecreaseTradersDebt AND decreaseTraderDebt ACCOUNT PERMANENT LOSS IN A DIFFERENT WAY

    // Medium

    Description

    The functions decreaseTraderDebt and batchDecreaseTradersDebt of the Bucket.sol contract account the permanentLossScaled global state variable differently.

    On the decreaseTraderDebt function, the liquidityIndex is first updated and the used the new value to compute the permanentLossScaled value. Whereas on the batchDecreaseTradersDebt function, the current liquidityIndex is used to compute the permanentLossScaled and then the index is updated.

    Code Location

    Bucket.sol#L365C1-L383C6

    Bucket.sol

    function decreaseTraderDebt(
        address _trader,
        uint256 _debtToBurn,
        address _receiverOfAmountToReturn,
        uint256 _amountToReturn,
        uint256 _permanentLossAmount
    ) external override onlyRole(PM_ROLE) {
        // don't need require on isLaunched,
        // because if we can't openPosition in this bucket then we can't closePosition in this bucket
        if (_amountToReturn != 0) {
            TokenTransfersLibrary.doTransferOut(address(borrowedAsset), _receiverOfAmountToReturn, _amountToReturn);
        }
        _updateIndexes();
        debtToken.burn(_trader, _debtToBurn, variableBorrowIndex);
        _updateRates();
        if (_permanentLossAmount != 0) {
            permanentLossScaled += _permanentLossAmount.rdiv(liquidityIndex);
        }
    }
    

    Bucket.sol#L388-L407

    Bucket.sol

    function batchDecreaseTradersDebt(
        address[] memory _traders,
        uint256[] memory _debtsToBurn,
        address _receiverOfAmountToReturn,
        uint256 _amountToReturn,
        uint256 _permanentLossAmount,
        uint256 _length
    ) external override onlyRole(BATCH_MANAGER_ROLE) {
        // don't need require on isLaunched,
        // because if we can't openPosition in this bucket then we can't closePosition in this bucket
        if (_amountToReturn != 0) {
            TokenTransfersLibrary.doTransferOut(address(borrowedAsset), _receiverOfAmountToReturn, _amountToReturn);
        }
        if (_permanentLossAmount != 0) {
            permanentLossScaled += _permanentLossAmount.rdiv(liquidityIndex);
        }
        _updateIndexes();
        debtToken.batchBurn(_traders, _debtsToBurn, variableBorrowIndex, _length);
        _updateRates();
    }
    
    BVSS
    Recommendation

    Consider which is the appropriate way to handle the accounting of permanent loss and implement it consistently on both function.

    Remediation

    SOLVED: The Primex team solved the issue by implementing suggestions consistently on both of the functions.

    Commit IDs:

    7.4 CHAINLINK latestRoundData MIGHT RETURN INCORRECT RESULTS

    // Medium

    Description

    The getExchangeRate function from the PriceOracle.sol contract calls the latestRoundData function from ChainLink price feeds. However, there is no check on the return values to validate stale data prices.

    This could lead to stale prices according to the ChainLink documentation:

    ChainLink Doc. Ref#1ChainLink Doc. Ref#2

    Code Location

    PriceOracle.sol#L73-L97

    PriceOracle.sol

    function getExchangeRate(address assetA, address assetB) external view override returns (uint256, bool) {
        address priceFeed = chainLinkPriceFeeds[assetA][assetB];
        bool isForward = true;
    
        if (priceFeed == address(0)) {
            priceFeed = chainLinkPriceFeeds[assetB][assetA];
            if (priceFeed == address(0)) {
                (address basePriceFeed, address quotePriceFeed) = getPriceFeedsPair(assetA, assetB);
    
                (, int256 basePrice, , , ) = AggregatorV3Interface(basePriceFeed).latestRoundData();
                (, int256 quotePrice, , , ) = AggregatorV3Interface(quotePriceFeed).latestRoundData();
    
                _require(basePrice > 0 && quotePrice > 0, Errors.ZERO_EXCHANGE_RATE.selector);
                //the return value will always be 18 decimals if the basePrice and quotePrice have the same decimals
                return (uint256(basePrice).wdiv(uint256(quotePrice)), true);
            }
            isForward = false;
        }
    
        (, int256 answer, , , ) = AggregatorV3Interface(priceFeed).latestRoundData();
        _require(answer > 0, Errors.ZERO_EXCHANGE_RATE.selector);
    
        uint256 answerDecimals = AggregatorV3Interface(priceFeed).decimals();
        return ((uint256(answer) * 10 ** (18 - answerDecimals)), isForward);
    }
    
    BVSS
    Recommendation

    Consider adding the next code, validate the oracle response.

    ( roundId , rawPrice , , updateTime , answeredInRound ) =
    AggregatorV3Interface(XXXXX).latestRoundData();
    require(rawPrice > 0, "Chainlink price <= 0");
    require(updateTime != 0, "Incomplete round");
    require(answeredInRound >= roundId , "Stale price");
    Remediation

    RISK ACCEPTED: The Primex team claims that accepting the risk of outdated oracle data is reasonable because it is logical not to block position liquidation or conditional closing based on such data. By the time the oracle reports the price, these positions should already be closed. Primex has an emergency pause system that allows EMERGENCY_ADMIN to pause borrowing funds from credit buckets and forbids opening new positions, etc. Each CL feed has a different heartbeat, and the normal heartbeat value is not stored on-chain, so the behavior of the oracle is monitored off-chain. If an insufficient price update frequency is detected, the corresponding components of the protocol will be paused until the oracle is stabilized. Additionally, the team will provide users in the Primex app with information regarding outdated oracle prices. In future versions, the team is considering implementing reserve oracles for critical situations like these.

    7.5 IMPLEMENTATION CONTRACTS CAN BE INITIALIZED

    // Low

    Description

    The implementation contracts of the protocol that are used by proxies do not disable the initialize function.

    An uninitialized contract can be taken over by an attacker, which map impact the proxy or be used for social engineering.

    BVSS
    Recommendation

    Consider adding the _disableInitializers function call on the constructor of each implementation.

    Remediation

    SOLVED: The Primex team solved the issue by using the _disableInitializers in the constructor.

    Commit ID: e9d04dedc78151cba0a759b90a3cbf6dd10add7c

    7.6 INCOMPATIBILITY WITH REBASING/DEFLATIONARY/INFLATIONARY TOKENS

    // Low

    Description

    Functions of Primex Protocol contracts use the TokenTransfersLibrary .sol to handle ERC20 transfers. Although this function returns the difference of balance of the caller before and after the call, this value is not used. As a result, the contract may account to a different value than what was actually transferred to the protocol.

    BVSS
    Recommendation

    Whenever tokens are transferred, the delta of the previous (before trans- fer) and current (after transfer) token balance should be verified to match the declared token amount.

    Remediation

    RISK ACCEPTED: The Primex team accepted the risk of this issue and will not use such tokens in the current version. These ERC20 tokens are planned to be future compatible.

    7.7 IF FEE IS PAID ON PMX CONTROL MSG.VALUE IS ZERO

    // Low

    Description

    The openPosition function of the PositionManager.sol contract allows paying the fee of the execution either on native currency or PMX. However, the payProtocolFee function does not control if the transaction contains msg.value when the function is executed, paying in PMX. This effectively locks the native currency on the PositionManager contract.

    Code Location

    PositionManager.sol

    PositionManager.sol

    function openPosition(
        PositionLibrary.OpenPositionParams calldata _params
    ) external payable override nonReentrant whenNotPaused {
        _notBlackListed();
        (PositionLibrary.Position memory newPosition, PositionLibrary.OpenPositionVars memory vars) = PositionLibrary
            .createPosition(_params, primexDNS, priceOracle);
        PositionLibrary.OpenPositionEventData memory posEventData = _openPosition(newPosition, vars);
    
        PositionLibrary.Position memory position = positions[positions.length - 1];
        _updateTraderActivity(msg.sender, position.positionAsset, position.positionAmount, position.bucket);
    

    PrimexPricingLibrary.sol#L444-L492

    PrimexPricingLibrary.sol

    function payProtocolFee(ProtocolFeeParams memory params) public returns (uint256 protocolFee) {
        ProtocolFeeVars memory vars;
        vars.treasury = params.primexDNS.treasury();
        vars.fromLocked = true;
    
        if (!params.isByOrder) {
            vars.fromLocked = false;
            params.depositData.protocolFee = getOracleAmountsOut(
                params.depositData.depositedAsset,
                params.feeToken,
                params.depositData.depositedAmount.wmul(params.depositData.leverage).wmul(
                    params.feeToken == NATIVE_CURRENCY
                        ? params.primexDNS.protocolRate()
                        : params.primexDNS.protocolRateInPmx()
                ),
                params.priceOracle
            );
            if (params.isSwapFromWallet) {
                if (params.feeToken == NATIVE_CURRENCY) {
                    _require(msg.value >= params.depositData.protocolFee, Errors.INSUFFICIENT_DEPOSIT.selector);
                    TokenTransfersLibrary.doTransferOutETH(vars.treasury, params.depositData.protocolFee);
                    if (msg.value > params.depositData.protocolFee) {
                        TokenTransfersLibrary.doTransferOutETH(
                            params.trader,
                            msg.value - params.depositData.protocolFee
                        );
                    }
                } else {
                    TokenTransfersLibrary.doTransferFromTo(
                        params.feeToken,
                        params.trader,
                        vars.treasury,
                        params.depositData.protocolFee
                    );
                }
                return params.depositData.protocolFee;
            }
        }
    
        params.traderBalanceVault.withdrawFrom(
            params.trader,
            vars.treasury,
            params.feeToken,
            params.depositData.protocolFee,
            vars.fromLocked
        );
    
        return params.depositData.protocolFee;
    }
    
    BVSS
    Recommendation

    Consider controlling the msg.value to give back the native currency or revert the transaction if appropriate.

    Remediation

    SOLVED: The Primex team solved the issue by adding the check for native currency.

    Commit IDs:

    7.8 CONTROL REVERT IF BUCKET DOES NOT HAVE BALANCE FOR BURNING ALL PTOKEN

    // Informational

    Description

    The Ptoken contract is used to account for the deposit and interest that lenders have on a specific bucket. The interest is not always accrued on time on the bucket, and a lender may attempt to burn its tokens without being enough funds on the bucket.

    In this case, the protocol reverts with an error from the ERC20 token contract indicating transferred failed.

    Code Location

    Bucket.sol#L314-L350

    Bucket.sol

    function withdraw(address _borrowAssetReceiver, uint256 _amount) external override nonReentrant notBlackListed {
        if (!LMparams.isLaunched) {
            LMparams.liquidityMiningRewardDistributor.removePoints(name, msg.sender, _amount);
        } else if (block.timestamp < LMparams.stabilizationPeriodEnd) {
            _require(
                _amount <=
                    pToken.balanceOf(msg.sender) -
                        LMparams.liquidityMiningRewardDistributor.getLenderAmountInMining(name, msg.sender),
                Errors.MINING_AMOUNT_WITHDRAW_IS_LOCKED_ON_STABILIZATION_PERIOD.selector
            );
        }
    
        _updateIndexes();
        uint256 amountToWithdraw = pToken.burn(msg.sender, _amount, liquidityIndex);
        uint256 amountToLender = (WadRayMath.WAD - withdrawalFeeRate).wmul(amountToWithdraw);
        uint256 amountToTreasury = amountToWithdraw - amountToLender;
        if (!LMparams.isLaunched && isInvestEnabled && aaveDeposit > 0) {
            // if liquidity mining failed, take all tokens from aave during first withdraw from bucket
            if (block.timestamp > LMparams.liquidityMiningDeadline) {
                _withdrawAllLiquidityFromAave();
            } else {
                // if liquidity mining is in progress, withdraw needed amount from aave
                address aavePool = dns.aavePool();
                IPool(aavePool).withdraw(address(borrowedAsset), amountToWithdraw, address(this));
                emit WithdrawFromAave(aavePool, amountToWithdraw);
                aaveDeposit -= amountToWithdraw;
            }
        }
    
        TokenTransfersLibrary.doTransferOut(address(borrowedAsset), dns.treasury(), amountToTreasury);
        emit TopUpTreasury(msg.sender, amountToTreasury);
    
        TokenTransfersLibrary.doTransferOut(address(borrowedAsset), _borrowAssetReceiver, amountToLender);
        _updateRates();
    
        emit Withdraw(msg.sender, _borrowAssetReceiver, amountToWithdraw);
    }
    

    PToken.sol#L169-L198

    PToken.sol

    function burn(address _user, uint256 _amount, uint256 _index) external override onlyBucket returns (uint256) {
        _require(_user != address(0), Errors.ADDRESS_NOT_SUPPORTED.selector);
        uint256 amountScaled;
        (_amount, amountScaled) = _getValidAmounts(_user, _amount, _index);
        if (address(interestIncreaser) != address(0)) {
            //in this case the _index will be equal to the getNormalizedIncome()
            try interestIncreaser.updateBonus(_user, scaledBalanceOf(_user), address(bucket), _index) {} catch {
                emit Errors.Log(Errors.INTEREST_INCREASER_CALL_FAILED.selector);
            }
        }
    
        if (address(lenderRewardDistributor) != address(0)) {
            try
                lenderRewardDistributor.updateUserActivity(
                    bucket,
                    _user,
                    scaledBalanceOf(_user),
                    (scaledTotalSupply() - amountScaled),
                    IActivityRewardDistributor.Role.LENDER
                )
            {} catch {
                emit Errors.Log(Errors.LENDER_REWARD_DISTRIBUTOR_CALL_FAILED.selector);
            }
        }
    
        _burn(_user, amountScaled);
    
        emit Burn(_user, _amount);
        return _amount;
    }
    
    Score
    Recommendation

    Consider controlling this scenario and revert with the corresponding error.

    Remediation

    SOLVED: The Primex team solved the issue by adding a require statement.

    Commit ID: 868eb9da1b22c03415b2d244a3bd836d76abf40a

    7.9 EMIT EVENT ON updateIndexes

    // Informational

    Description

    The function _updateIndexes function is a critical operation that updates the liquidityIndex and variableBorrowIndex storage variables. These variables are used to account the amounts that lenders and borrowers should receive/return. But this critical operation does not emit an event to help to monitor the protocol.

    Code Location

    Bucket.sol#L576-L590

    Bucket .sol

    function _updateIndexes() internal {
        uint256 cumulatedLiquidityInterest = _calculateLinearInterest(lar, lastUpdatedBlockTimestamp);
        uint256 newLiquidityIndex = cumulatedLiquidityInterest.rmul(liquidityIndex);
        _require(newLiquidityIndex <= type(uint128).max, Errors.LIQUIDITY_INDEX_OVERFLOW.selector);
        liquidityIndex = uint128(newLiquidityIndex);
    
        uint256 cumulatedVariableBorrowInterest = _calculateCompoundedInterest(bar, lastUpdatedBlockTimestamp);
        uint256 newVariableBorrowIndex = cumulatedVariableBorrowInterest.rmul(variableBorrowIndex);
        _require(newVariableBorrowIndex <= type(uint128).max, Errors.BORROW_INDEX_OVERFLOW.selector);
        uint256 previousVariableBorrowIndex = variableBorrowIndex;
        variableBorrowIndex = uint128(newVariableBorrowIndex);
    
        lastUpdatedBlockTimestamp = block.timestamp;
        _mintToReserve(debtToken.scaledTotalSupply(), previousVariableBorrowIndex, variableBorrowIndex);
    }
    
    Score
    Recommendation

    Consider omitting an event to help protocol monitoring.

    Remediation

    SOLVED: The Primex team solved the issue by adding another parameter to the event.

    Commit ID: 53578af0ae4c20291f414baab11df0525f25c635

    7.10 SOLIDITY VERSION 0.8.20 MAY NOT WORK ON OTHER CHAINS DUE TO PUSH0

    // Informational

    Description

    The introduction of EIP-3855 introduces a breaking change that prevents solidity compiled code to execute on L2 chains. As the protocol expects to work on different chains and contains the floating pragma ^0.8.18, it is advised not to use this solidity version.

    Score
    Recommendation

    Avoid using floating pragma that can result to compile with this solc version.

    Remediation

    SOLVED: The Primex team solved the issue by changing the floating pragma with 0.8.18.

    Commit ID: 2a2dcc15ec03833d5a7bc42c8c1c29a8f251d583

    7.11 USE SHIFT RIGHT/LEFT INSTEAD OF DIVISION MULTIPLICATION IF POSSIBLE

    // Informational

    Description

    While the DIV opcode uses 5 gas, the SHR opcode only uses 3 gas. Furthermore, Solidity’s division operation also includes a division-by-0 prevention, which is bypassed using shifting.

    Code Location

    FeeExecutor.sol#L311

    FeeExecutor.sol

    while (lowest < highest) {
        if (lowest == highest - 1) break;
        uint256 mid = (lowest + highest) / 2;
        uint256 midTimestamp = updatedTimestamps[mid];
        if (_bonusDeadline < midTimestamp) {
            highest = mid;
        } else if (_bonusDeadline > midTimestamp) {
            lowest = mid;
        } else {
            return indexes[midTimestamp];
        }
    }
    

    Bucket.sol#L714-L715

    Bucket.sol

    function _calculateCompoundedInterest(uint256 _bar, uint256 _blockTimestamp) internal view returns (uint256) {
        uint256 exp = block.timestamp - _blockTimestamp;
    
        if (exp == 0) {
            return WadRayMath.RAY;
        }
    
        uint256 expMinusOne = exp - 1;
        uint256 expMinusTwo = exp > 2 ? exp - 2 : 0;
        // multiply first to mitigate rounding related issues
        uint256 basePowerTwo = _bar.rmul(_bar) / (SECONDS_PER_YEAR * SECONDS_PER_YEAR);
        uint256 basePowerThree = _bar.rmul(_bar).rmul(_bar) / (SECONDS_PER_YEAR * SECONDS_PER_YEAR * SECONDS_PER_YEAR);
    
        uint256 secondTerm = (exp * expMinusOne * basePowerTwo) / 2;
        uint256 thirdTerm = (exp * expMinusOne * expMinusTwo * basePowerThree) / 6;
    
        return WadRayMath.RAY + (_bar * exp) / SECONDS_PER_YEAR + secondTerm + thirdTerm;
    }
    

    PriceOracle.sol#L36-L39

    PriceOracle.sol

    function increasePairVolatility(
        address _assetA,
        address _assetB,
        uint256 _pairVolatility
    ) external override onlyRole(EMERGENCY_ADMIN) {
        _require(
            _pairVolatility > pairVolatilities[_assetA][_assetB] && _pairVolatility <= WadRayMath.WAD / 2,
            Errors.PAIRVOLATILITY_IS_NOT_CORRECT.selector
        );
        _setPairVolatility(_assetA, _assetB, _pairVolatility);
    }
    

    WadRayMath.sol#L17-L34

    WadRayMath.sol

        function wmul(uint256 x, uint256 y) internal pure returns (uint256 z) {
            z = add(mul(x, y), WAD / 2) / WAD;
        }
    
        //rounds to zero if x*y < WAD / 2
        function rmul(uint256 x, uint256 y) internal pure returns (uint256 z) {
            z = add(mul(x, y), RAY / 2) / RAY;
        }
    
        //rounds to zero if x*y < WAD / 2
        function wdiv(uint256 x, uint256 y) internal pure returns (uint256 z) {
            z = add(mul(x, WAD), y / 2) / y;
        }
    
        //rounds to zero if x*y < RAY / 2
        function rdiv(uint256 x, uint256 y) internal pure returns (uint256 z) {
            z = add(mul(x, RAY), y / 2) / y;
        }
    
    Score
    Recommendation

    Consider using a shift operator instead of diving by a constant to save gas.



    Remediation

    ACKNOWLEDGED: The Primex team acknowledged this finding.

    7.12 INITIALIZED VARIABLE TO DEFAULT VALUE

    // Informational

    Description

    Initializing variables to the default value executes an extra order that is not required.

    Code Location

    FeeExecutor.sol#L294

    FeeExecutor.sol

     uint256 lowest = 0;
    

    UniswapInterfaceMulticall.sol#L30

    UniswapInterfaceMulticall.sol

    for (uint256 i = 0; i < calls.length; i++) {
    

    LimitOrderLibrary.sol#L469

    LimitOrderLibrary.sol

    for (uint256 i = 0; i < A.length - 1; i++) {
    
    Score
    Recommendation

    Consider avoiding initializing variables to default value.

    Remediation

    SOLVED: The Primex team solved the issue by avoiding initializing variables to default value.

    Commit ID: 694e59382fb9e378fecef1ef23af23b097c7bc10

    7.13 CACHE ARRAY LENGTH OUTSIDE OF LOOP

    // Informational

    Description

    If not cached, the solidity compiler will always read the length of the array during each iteration. That is, if it is a storage array, this is an extra sload operation (100 additional extra gas for each iteration except for the first) and if it is a memory array, this is an extra mload operation (3 additional gas for each iteration except for the first).

    The identified loops withing the protocol that can be optimized are:

    • ActivityRewardDistributor.sol L89, L250

    • BatchManager.sol L90, L133, L158, L173, L189, L229, L242, L291

    • FeeExecutor.sol L51, L87

    • Bucket.sol L76

    • DexAdapter.sol L221, L300, L359, L442

    • EPMXToken.sol L42, L54

    • LimitOrderManager.sol L86, L103, L180, L200, L257, L282

    • ReferralProgram.sol L83, L94, L95

    • BalancerBotLens.sol L15, L31, L61

    • TraderBalanceVault.sol L133

    • UniswapInterfaceMulticall.sol L30

    • WhiteBlackListBase L33, L45, L51, L65

    • BestDexLens.sol L283

    • PrimexLens.sol L136, L249, L282, L316, L494

    • LimitOrderLibrary L274, L302, L469, L470

    • PositionLibrary.sol L480

    • PrimexPricingLibrary.sol L172, L139, L145, L176, L188, L193, L268, L272, L324, L333, L341

    Score
    Recommendation

    Consider caching the array length before iterating over it.



    Remediation

    ACKNOWLEDGED: The Primex team acknowledged the issue.

    7.14 USE ++i INSTEAD OF i++ ON FOR LOOPS

    // Informational

    Description

    Using ++i instead of i++ saves 5 gas per loop iteration.

    The identified loops withing the protocol that can be optimized are:

    • ActivityRewardDistributor.sol L79, L89, L250

    • BatchManager.sol L90, L158, L189, L229, L242, L291

    • FeeExecutor.sol L51, L87, L131

    • Bucket.sol L76, L464

    • DebtToken L142, L154

    • DexAdapter.sol L221, L300, L359, L442, L462, L478

    • EPMXToken.sol L42, L54

    • LimitOrderManager.sol L90, L221, L225

    • PrimexUpkeep.sol L86, L103, L129, L147, L180, L200, L232, L257, L282, L310

    • ReferralProgram.sol L83, L94, L95

    • SpotTradingRewardDistributor.sol L160

    • BalancerBotLens.sol L15, L31, L61

    • TraderBalanceVault.sol L133

    • UniswapInterfaceMulticall.sol L30

    • WhiteBlackListBase L33, L45, L51, L65

    • BestDexLens.sol L283

    • PrimexLens.sol L136, L249, L282, L316, L494

    • LimitOrderLibrary L274, L302, L469, L470

    • PositionLibrary.sol L480

    • PrimexPricingLibrary.sol L172, L139, L145, L176, L188, L193, L268, L272, L324, L333, L341

    Score
    Recommendation

    Consider replacing i++ to ++i in all indicated for loops.

    Remediation

    ACKNOWLEDGED: The Primex team acknowledged the issue.

    7.15 USE CUSTOM ERRORS

    // Informational

    Description

    Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met). Source Custom Errors in Solidity: Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to provide additional information about failures (e.g., revert(“Insufficient funds.“);), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.

    Code Location

    PrimexPricingLibrary.sol#L553

    PrimexPricingLibrary.sol

    revert("DexAdapter::decodePath: UNKNOWN_DEX_TYPE");
    

    WadRayMath.sol#L6-L10

    WadRayMath.sol

    function add(uint256 x, uint256 y) internal pure returns (uint256 z) {
        require((z = x + y) >= x, "ds-math-add-overflow");
    }
    
    function mul(uint256 x, uint256 y) internal pure returns (uint256 z) {
        require(y == 0 || (z = x * y) / y == x, "ds-math-mul-overflow");
    }
    
    Score
    Recommendation

    Consider replacing strings for custom errors, as done in the rest of the protocol implementation.



    Remediation

    SOLVED: The Primex team solved the issue by using custom errors.

    Commit ID: b10edc72fe57411bfeff984f92805d560f3e28eb

    7.16 USING BOOLS FOR STORAGE INCURS OVERHEAD

    // Informational

    Description

    Use uint256(1) and uint256(2) for true/false to avoid a Gwarmaccess (100 gas), and to avoid Gsset (20000 gas) when changing from ‘false’ to ‘true’, after having been ‘true’ in the past. See source.

    Code Location

    Instances (3):

    File: Bucket/BucketStorage.sol
    
    57:     bool public isInvestEnabled;
    
    
    File: EPMXToken.sol
    
    14:     mapping(address => bool) public whitelist;
    
    
    File: PMXBonusNFT/PMXBonusNFTStorage.sol
    
    27:     mapping(uint256 => bool) internal isBlocked;
    
    
    Score
    Recommendation

    Consider avoiding the usage of boolean types for storage variables.


    Remediation

    ACKNOWLEDGED: The Primex team acknowledged the issue.

    7.17 FLOATING PRAGMA

    // Informational

    Description

    Primex protocol contract uses the floating pragma ^0.8.18. 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 another pragma, for example, either an outdated pragma version that might introduce bugs that affect the contract system negatively or a recently released pragma version which has not been extensively tested.

    This issue, specifically, aligns with the previous described misbehave on different chains if solidity version 0.8.20 is used.

    Score
    Recommendation

    Consider locking the pragma version, known bugs for the compiler version. Therefore, it is recommended not to use floating pragma in production.

    Remediation

    SOLVED: The Primex team solved the issue by locking pragma to 0.8.18.

    Commit ID: 2a2dcc15ec03833d5a7bc42c8c1c29a8f251d583

    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.

    Results

    • No major issues found by Slither.

    AUTOMATED SECURITY SCAN

    Description

    Halborn used automated security scanners to assist with detection of well-known security issues and to identify low-hanging fruits on the targets for this engagement. Among the tools used was MythX, a security analysis service for Ethereum smart contracts. MythX performed a scan on the smart contracts and sent the compiled results to the analyzers in order to locate any vulnerabilities.

    Results

    • No major issues were found by MythX.

    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.