Prepared by:
HALBORN
Last Updated 11/13/2024
Date of Engagement by: June 19th, 2023 - August 25th, 2023
100% of all REPORTED Findings have been addressed
All findings
17
Critical
0
High
0
Medium
4
Low
3
Informational
10
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.
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:
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
)
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
0
Medium
4
Low
3
Informational
10
Security analysis | Risk level | Remediation Date |
---|---|---|
NON-STANDARD ERC20 TOKENS WILL REVERT | Medium | Solved - 09/15/2023 |
RELAX STRICT CONDITIONS ON SWAPS WITHOUT DEBT | Medium | Solved - 09/15/2023 |
batchDecreaseTradersDebt AND decreaseTraderDebt ACCOUNT PERMANENT LOSS IN A DIFFERENT WAY | Medium | Solved - 09/15/2023 |
CHAINLINK latestRoundData MIGHT RETURN INCORRECT RESULTS | Medium | Risk Accepted - 08/25/2023 |
IMPLEMENTATION CONTRACTS CAN BE INITIALIZED | Low | Solved - 09/15/2023 |
INCOMPATIBILITY WITH REBASING/DEFLATIONARY/INFLATIONARY TOKENS | Low | Risk Accepted - 08/25/2023 |
IF FEE IS PAID ON PMX CONTROL MSG.VALUE IS ZERO | Low | Solved - 09/15/2023 |
CONTROL REVERT IF BUCKET DOES NOT HAVE BALANCE FOR BURNING ALL PTOKEN | Informational | Solved - 09/15/2023 |
EMIT EVENT ON updateIndexes | Informational | Solved - 09/15/2023 |
SOLIDITY VERSION 0.8.20 MAY NOT WORK ON OTHER CHAINS DUE TO PUSH0 | Informational | Solved - 09/15/2023 |
USE SHIFT RIGHT/LEFT INSTEAD OF DIVISION MULTIPLICATION IF POSSIBLE | Informational | Acknowledged - 09/15/2023 |
INITIALIZED VARIABLE TO DEFAULT VALUE | Informational | Solved - 09/15/2023 |
CACHE ARRAY LENGTH OUTSIDE OF LOOP | Informational | Acknowledged - 05/15/2023 |
USE ++i INSTEAD OF i++ ON FOR LOOPS | Informational | Acknowledged - 09/15/2023 |
USE CUSTOM ERRORS | Informational | Solved - 09/15/2023 |
USING BOOLS FOR STORAGE INCURS OVERHEAD | Informational | Acknowledged - 09/15/2023 |
FLOATING PRAGMA | Informational | Solved - 09/15/2023 |
// Medium
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.
TokenTransfersLibrary.sol#L12-L19
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
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;
Consider using a non-strict interface, as compound does, to transfer ERC20 tokens.
SOLVED: The Primex team solved the issue by using a non-strict interface.
Commit ID:
88d33deeebf9c169d21e333ef871c518b10e0b33
// Medium
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.
PrimexPricingLibrary.sol#L353-L359
if (_needOracleTolerableLimitCheck) {
_require(
vars.balance >=
getOracleAmountsOut(_params.tokenA, _params.tokenB, _params.amountTokenA, _priceOracle).wmul(
WadRayMath.WAD - _maximumOracleTolerableLimit
),
Errors.DIFFERENT_PRICE_DEX_AND_ORACLE.selector
);
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.
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
// Medium
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.
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);
}
}
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();
}
Consider which is the appropriate way to handle the accounting of permanent loss and implement it consistently on both function.
SOLVED: The Primex team solved the issue by implementing suggestions consistently on both of the functions.
Commit IDs:
// Medium
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
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);
}
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");
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.
// Low
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.
Consider adding the _disableInitializers
function call on the constructor of each implementation.
SOLVED: The Primex team solved the issue by using the _disableInitializers
in the constructor.
Commit ID:
e9d04dedc78151cba0a759b90a3cbf6dd10add7c
// Low
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.
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.
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.
// Low
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.
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
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;
}
Consider controlling the msg.value
to give back the native currency or revert the transaction if appropriate.
SOLVED: The Primex team solved the issue by adding the check for native currency.
Commit IDs:
// Informational
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.
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);
}
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;
}
Consider controlling this scenario and revert with the corresponding error.
SOLVED: The Primex team solved the issue by adding a require
statement.
Commit ID:
868eb9da1b22c03415b2d244a3bd836d76abf40a
// Informational
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.
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);
}
Consider omitting an event to help protocol monitoring.
SOLVED: The Primex team solved the issue by adding another parameter to the event.
Commit ID:
53578af0ae4c20291f414baab11df0525f25c635
// Informational
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.
Avoid using floating pragma that can result to compile with this solc version.
SOLVED: The Primex team solved the issue by changing the floating pragma with 0.8.18.
Commit ID:
2a2dcc15ec03833d5a7bc42c8c1c29a8f251d583
// Informational
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.
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];
}
}
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;
}
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);
}
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;
}
Consider using a shift operator instead of diving by a constant to save gas.
ACKNOWLEDGED: The Primex team acknowledged this finding.
// Informational
Initializing variables to the default value executes an extra order that is not required.
uint256 lowest = 0;
UniswapInterfaceMulticall.sol#L30
for (uint256 i = 0; i < calls.length; i++) {
for (uint256 i = 0; i < A.length - 1; i++) {
Consider avoiding initializing variables to default value.
SOLVED: The Primex team solved the issue by avoiding initializing variables to default value.
Commit ID:
694e59382fb9e378fecef1ef23af23b097c7bc10
// Informational
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
Consider caching the array length before iterating over it.
ACKNOWLEDGED: The Primex team acknowledged the issue.
// Informational
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
Consider replacing i++ to ++i in all indicated for loops.
ACKNOWLEDGED: The Primex team acknowledged the issue.
// Informational
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.
revert("DexAdapter::decodePath: UNKNOWN_DEX_TYPE");
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");
}
Consider replacing strings for custom errors, as done in the rest of the protocol implementation.
SOLVED: The Primex team solved the issue by using custom errors.
Commit ID:
b10edc72fe57411bfeff984f92805d560f3e28eb
// Informational
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.
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;
Consider avoiding the usage of boolean types for storage variables.
ACKNOWLEDGED: The Primex team acknowledged the issue.
// Informational
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.
Consider locking the pragma version, known bugs for the compiler version. Therefore, it is recommended not to use floating pragma in production.
SOLVED: The Primex team solved the issue by locking pragma to 0.8.18.
Commit ID:
2a2dcc15ec03833d5a7bc42c8c1c29a8f251d583
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.
No major issues found by Slither.
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.
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.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed