DeXe Audit

CertiKFiles named in the report

CertiK reviewed DeXe Protocol (DeXe Audit) on 4 May 2023: 27 findings, 0 critical; 15 marked fixed, 1 acknowledged, 11 open.

No open critical findings
Open the reportOpen DeXe Protocol’s security record

Report hosted on skynet.certik.com.

Trustblock does not review code.

Findings27

Statuses are the security firm’s own, from its report.

SeverityOpenAcknowledgedFixed
Critical000
High011
Medium000
Low11014
HighPotential Flashloan Attack to Execute ProposalFixed

According to the design of `GovPool`, the creator of the gov pool has the option to set the `earlyCompletion` parameter to true and the `validatorsVote` parameter to false, indicating that voting will complete as soon as the quorum is achieved without an additional validator step. However, this option can make the proposal vulnerable to flashloan attacks, where someone can instantly borrow large amounts of tokens to reach the quorum and execute the proposal. ```solidity=387 if (core.settings.earlyCompletion || voteEnd < block.timestamp) { if (_quorumReached(core)) { if (core.settings.validatorsVote && _govValidators.validatorsCount() > 0) { ... } else { return ProposalState.Succeeded; } } ``` Additionally, according to the design of the `GovPoolCreate` library, the function determines the type of proposal based solely on the value of `executors[executors.length - 1]`. If the value matches the address of `GovSetting`, `GovUserKeeper`, or `GovPool` contract, the proposal is considered internal. The `_handleDataForInternalProposal()` function then checks whether the proposal's `data` is legitimate. For instance, only the `setERC20Address()` and `setERC721Address()` functions in the `GovUserKeeper` contract can be executed through proposals. However, an attacker can change the value of `executors[executors.length - 1]` to any other contract and place the address of `GovUserKeeper` contract in another position. As a result, the `_handleDataForInternalProposal()` will be bypassed, allowing the attacker to perform other dangerous operations in the `GovUserKeeper` contract, such as `unlockToken()` and `withdrawToken()`, to steal the deposited tokens from other users. ```solidity=118 function _validateProposal( address[] calldata executors, uint256[] calldata values, bytes[] calldata data ){ ... address mainExecutor = executors[executors.length - 1]; settingsId = IGovSettings(govSettings).executorToSettings(mainExecutor); ... if (settingsId == uint256(IGovSettings.ExecutorType.INTERNAL)) { _handleDataForInternalProposal(govSettings, executors, values, data); } else if (...){ ... } ... } function __handleDataForInternalProposal(){ ... require( values[i] == 0 && executorSettings == uint256(IGovSettings.ExecutorType.INTERNAL) && (selector == IGovSettings.addSettings.selector || selector == IGovSettings.editSettings.selector || selector == IGovSettings.changeExecutors.selector || selector == IGovUserKeeper.setERC20Address.selector || selector == IGovUserKeeper.setERC721Address.selector || selector == IGovPool.editDescriptionURL.selector || selector == IGovPool.setNftMultiplierAddress.selector || selector == IGovPool.changeVerifier.selector), "Gov: invalid internal data" ); ... } ```

LowBlacklisted Position Tokens Cannot be ClosedFixed

According to the logic of `TraderPoolExchange`, the `exchange()` function facilitates the exchange of `from` token to `to` token in the trader pool. Input validation is performed to ensure that the `from` token and `to` token are not blacklisted. ```solidity=43 require( !coreProperties.isBlacklistedToken(from) && !coreProperties.isBlacklistedToken(to), "TP: blacklisted token" ); ``` ```solidity=79 if (from != poolParameters.baseToken && from.thisBalance() == 0) { positions.remove(from); emit PositionClosed(from); } ``` However, if the `coreProperties` contract adds new blacklisted tokens via the `addBlacklistTokens()` function after an exchange operation, traders will be unable to close blacklisted position tokens since the remove operation in line 80 will never be reached. Consequently, the condition `traderPool.openPositions().length == 0` will never be true, and traders will be unable to perform divest operations in the `divest()` function of the `TraderPoolDivest` library. ```solidity=40 require(!senderTrader || traderPool.openPositions().length == 0, "TP: can't divest"); ```

LowDivide Before MultiplyFixed

Performing integer division before multiplication truncates the low bits, losing the precision of calculation. ## Function `buy()`: The linked code implements the calculation: $\text{saleTokenAmount} = \text{amount} * \text{exchangeRate} / \text{PRECISION}$. e.g. Suppose - amount = 15 - exchangeRate = 1/2 * PRECISION - tierView.vestingSettings.vestingPercentage = 2/10 * PERCENTAGE_100 We have `saleTokenAmount = 15 * 1/2 * PRECISION / PRECISION = 7`. Then `saleTokenAmount.percentage(PERCENTAGE_100 - tierView.vestingSettings.vestingPercentage) = 7 * (PERCENTAGE_100 - 2/10 * PERCENTAGE_100)/PERCENTAGE_100 = 5`. If the function applies multiplication before division, then the aforementioned result will be `6` instead of `5`. ## Function `_countPrefixVestingAmount()`: If `vestingSettings.unlockStep` is not a divisor of `vestingSettings.vestingDuration`, `purchase.vestingTotalAmount` may never be reached. e.g. Suppose - unlockStep == 3 - vestingDuration == 10 - vestingTotalAmount == 10 We have `stepsCount == vestingDuration / unlockStep = 10/3 = 3` and `tokensPerStep == vestingTotalAmount / stepsCount = 10/3 = 3` Then the maximum return value of `_countPrefixVestingAmount()` is `(vestingDuration / unlockStep) * tokensPerStep = (10 / 3) * 3 = 3 * 3 = 9 < vestingTotalAmount`.

LowInconsistent DECIMAL StandardFixed

According to the entire protocol's design, most internal calculations utilize 18 decimal places for tokens, with the exception of the TokenSaleProposal contract. In particular, the exchange rate between `tokenToBuyWith` and `saleToken`, as well as the transfer operations within this contract, may handle tokens incorrectly.

LowIncorrect Calculation of `nextUnlockTime`Fixed

If the current time `block.timestamp` is before the `vestingView.cliffEndTime`, the `nextUnlockTime` is calculated as ```solidity=257 vestingView.nextUnlockTime = purchase.purchaseTime + uint64(uint256(vestingSettings.cliffPeriod).max(vestingSettings.unlockStep)); ``` However, `unlockStep` represents the tick step with which vested funds are given to the buyer, so it should instead be `purchase.purchaseTime + uint64(uint256(vestingSettings.cliffPeriod).max(vestingSettings.vestingDuration / vestingSettings.unlockStep))`. Similarly for the case when the current time `block.timestamp` is at least `vestingView.cliffEndTime`, `vestingSettings.unlockStep` should be replaced with `vestingSettings.vestingDuration / vestingSettings.unlockStep`. Another issue is that even with the above changes, the calculation of the unlock time can still be incorrect due to integer truncation. Let's suppose all mentions of `unlockStep` are replaced with `vestingSettings.vestingDuration / vestingSettings.unlockStep`. If `unlockStep` is not a divisor of `vestingDuration`, then it is possible for `nextUnlockTime` to be incorrect. The following example goes through the calculations shown in the code snippet: ```solidity=261 vestingView.nextUnlockTime = uint64(block.timestamp) + vestingSettings.unlockStep; vestingView.nextUnlockTime -= (vestingView.nextUnlockTime - purchase.purchaseTime) % vestingSettings.unlockStep; if (vestingView.nextUnlockTime > vestingView.vestingEndTime) { vestingView.nextUnlockTime = 0; } ``` e.g. Suppose - purchaseTime == 0 - vestingDuration == 10 - unlockStep == 3 - no cliff period - block.timestamp == 9 Then we have `vestingEndTime == 10`. In the calculation of `nextUnlockTime`, we have `nextUnlockTime == block.timestamp + vestingDuration / unlockStep = 9 + 10/3 = 12`. `nextUnlockTime -= (nextUnlockTime - purchaseTime) % (vestingDuration / unlockStep) = 12 % 3 = 0`. So `nextUnlockTime` is unchanged (still 12). As `nextUnlockTime == 12 > 10 == vestingEndTime`, we change `nextUnlockTime` to 0, when it should actually be 10.

LowLack of Storage Gap in Upgradeable ContractFixed

There is no storage gap preserved in the logic contract. Any logic contract that acts as a base contract that needs to be inherited by other upgradeable child should have a reasonable size of storage gap preserved for the new state variable introduced by the future upgrades.

LowLack of Validation for `documentHash`Fixed

The `UserRegistry` contract stores the hash of the privacy policy in the variable `documentHash`, which users must agree to by signing the `Agreement` struct (using EIP712) to verify their agreement. Once the signature is verified, its hash is stored in `userInfo[msg.sender].signatureHash`. The view function `agreed()` can determine if a user has agreed to the policy by checking if `userInfor[msg.sender].signatureHash` is non-zero. ```solidity=53 function agreed(address user) external view override returns (bool) { return userInfos[user].signatureHash != 0; } function setPrivacyPolicyDocumentHash(bytes32 hash) external override onlyOwner { documentHash = hash; } ``` However, there is no validation to confirm the relationship between `signatureHash` and the current `documentHash`. The owner of the contract can change the `documentHash` using the function `setPrivacyPolicyDocumentHash()`, meaning that users who agreed to the previous policy will also agree to updates made to the policy.

LowMissing Approve Operation for `rewardToken` fromFixed

The linked code allows `DistributionProposal` contract to transfer a certain amount of `rewardToken` tokens from `govAddress` to this contract. However, `approve()` operation is missing to grant approval to `DistributionProposal` contract. Without sufficient allowance, `DistributionProposal` contract is unable to transfer `rewardToken` tokens from `govAddress` account. ```solidity=75 if (address(rewardToken) == ETHEREUM_ADDRESS) { (bool status, ) = payable(voter).call{value: reward}(""); require(status, "DP: failed to send eth"); } else { if (balance < reward) { rewardToken.safeTransferFrom(govAddress, address(this), reward - balance); } rewardToken.safeTransfer(voter, reward); ```

LowNo Update for `executed` of External ProposalFixed

According to the design of the `GovValidators` contract, the `createExternalProposal` contract receives the proposal from the `GovPool` contract, storing it in `_externalProposals[proposalId]` for voting. Once approved, the proposal can be executed through the `execute()` function in the `GovPool` contract. However, the `execute()` function does not update the state of `executed` in `_externalProposals[proposalId]`, which can lead to inaccuracies in the `getProposalState()` function. ```solidity=122 _externalProposals[proposalId] = ExternalProposal({ core: ProposalCore({ voteEnd: uint64(block.timestamp + duration), executed: false, quorum: quorum, votesFor: 0, snapshotId: uint32(govValidatorsToken.snapshot()) }) }); ```

LowNon-guaranteed Reward DistributionFixed

According to the design of the `TraderPoolInvestProposal` contract, the `convertInvestedBaseToDividend()` function is to convert newly invested funds to rewards. Reward distribution of the base token can be very different depending on when `convertInvestedBaseToDividends()` is called. For example, suppose userA is the first user to invest base tokens and userB is the second user to invest base tokens. Case 1: `convertInvestedBaseToDividends()` is called after userA but before userB - userA's investment will be given as rewards only to userA - If `convertInvestedBaseToDividends()` is called after userB invests, userB's investment is given to both userA and userB as rewards Case 2: `convertInvestedBaseToDividends()` is called after both users invest and not before - userA's investment and userB's investment will be given as rewards to both userA and userB So the comparison of the two is that userA gets more rewards in Case 1 and less in Case 2 while the opposite is true for userB.

Show 5 more
LowPotential Flashloan Operation to Bypass `_checkUsFixed

According to the design of the library `TraderPoolDivest`, the `_checkUserBalance()` function verifies whether the LP token amounts to be divested are less than the user's LP token balance (minus the amount of LP tokens received through investment in the current block) to prevent users from investing and divesting tokens in the same block. ```solidity=133 require( amountLP <= traderPool.balanceOf(msg.sender) - traderPool.investsInBlocks(msg.sender, block.number), "TP: wrong amount" ); ``` However, if the traderPool's LP token can be borrowed through a flashloan, the value of `traderPool.balanceOf(msg.sender)` may be increased to an arbitrary value, which can bypass the `_checkUserBalance()` function.

LowPotential Flashloan to Bypass the Check `canPartiFixed

The function `canParticipate()` checks if the `voter` has sufficient balance to participate in voting. However, the value of ` ERC20(tokenAddress).balanceOf(voter)` in the `tokenBalance()` function can be manipulated by the `voter` through a flashloan if `isMicropool` is set to `false`. The `voter` can take out a flash loan and use the borrowed funds to increase the `tokenBalance()`, then the following check can be easily bypassed: ```solidity=547 (uint256 tokens, ) = tokenBalance(voter, isMicropool, useDelegated); if (tokens >= requiredVotes) { return true; } ``` ```solidity=404 function tokenBalance( address voter, bool isMicropool, bool useDelegated ) public view override returns (uint256 totalBalance, uint256 ownedBalance) { if (tokenAddress == address(0)) { return (0, 0); } totalBalance = _getBalanceInfoStorage(voter, isMicropool).tokenBalance; if (!isMicropool) { if (useDelegated) { ... } ownedBalance = ERC20(tokenAddress).balanceOf(voter).to18( ERC20(tokenAddress).decimals() ); totalBalance += ownedBalance; } } ``` This allows the user to create a proposal when they may not be able to.

LowPotential Inaccurate Update of `_proposalInfos[prFixed

According to the design of the `TraderPoolInvestProposal` contract, the purpose of the divest() function is to transfer profits from the specified investment proposal to the main pool and users. Upon calculating the rewards, the variable `claim[0]` - the amount of baseToken to be rewarded - is subtracted from both the `_proposalInfos[proposalId].lpLocked` and `totalLockedLP`. However, these two variables serve as trackers for changes in the trader pool's LP token. The function `min()` is utilized to prevent underflow errors, given that the reward base token could potentially exceed the invested base token. However, these two public variables may not be accurate. ```solidity=144 _proposalInfos[proposalId].lpLocked -= claimed[0].min( _proposalInfos[proposalId].lpLocked ); _updateFromData(user, proposalId, claimed[0]); investedBase -= claimed[0].min(investedBase); totalLockedLP -= claimed[0].min(totalLockedLP); ```

LowPotential Maliciously Locking Delegators' TokensFixed

In the contract `GovUserKeeper`, the function `delegateTokens()` and `delegateNfts()` allow a delegator to delegate a certain amount of tokens or Nfts to a specified `delegatee`. The functions `undelegateTokens()` and `undelegateNfts()` can undelegate tokens or Nfts from a `delegatee`. However, a delegatee is able to call the function `createProposal()` in the contract `GovPool` to create a proposal. By voting on the created proposal, the `delegatee` updates the value of `micropoolInfo.maxTokensLocked`. Consequently, the delegators are unable to undelegate their tokens and NFTs since the following condition cannot be true: ```solidity=149 uint256 availableAmount = micropoolInfo.tokenBalance - micropoolInfo.maxTokensLocked; require( amount <= delegated && amount <= availableAmount, "GovUK: amount exceeds delegation" ); ```

LowUnable to Unlock NftFixed

The linked function `lock()` transfers NFT from `msg.sender` to `address(this)`. However, there is no other function in place to transfer the locked NFT back to its original owner. ```solidity=25 function lock(uint256 tokenId) external override { require( !isLocked(_latestLockedTokenIds[msg.sender]), "ERC721Multiplier: Cannot lock more than one nft" ); _transfer(msg.sender, address(this), tokenId); ... ```

Contracts in scope

No deployed address is recorded for this audit.

The report names a repository or files. Open the report to see its scope.

Repository and files the report names (53)

  • contracts/core/CoreProperties.sol
  • contracts/libs/trader-pool/TraderPoolInvest.sol
  • contracts/libs/price-feed/UniswapV2PathFinder.sol
  • contracts/trader/InvestTraderPool.sol
  • contracts/libs/gov-pool/GovPoolCommission.sol
  • contracts/core/ContractsRegistry.sol
  • contracts/factory/PoolFactory.sol
  • contracts/core/Globals.sol
  • contracts/libs/trader-pool/TraderPoolPrice.sol
  • contracts/libs/trader-pool/TraderPoolView.sol
  • contracts/libs/trader-pool-proposal/TraderPoolRiskyProposalView.sol
  • contracts/libs/trader-pool/TraderPoolDivest.sol
  • contracts/libs/gov-user-keeper/GovUserKeeperLocal.sol
  • contracts/libs/factory/GovTokenSaleDeployer.sol
  • contracts/libs/trader-pool/TraderPoolModify.sol
  • contracts/libs/gov-pool/GovPoolView.sol
  • contracts/gov/validators/GovValidatorsToken.sol
  • contracts/libs/gov-pool/GovPoolExecute.sol
  • contracts/libs/gov-pool/GovPoolRewards.sol
  • contracts/gov/ERC20/ERC20Sale.sol
  • contracts/libs/trader-pool-proposal/TraderPoolInvestProposalView.sol
  • contracts/gov/user-keeper/GovUserKeeper.sol
  • contracts/libs/gov-pool/GovPoolVote.sol
  • contracts/libs/utils/TokenBalance.sol
  • contracts/libs/gov-pool/GovPoolUnlock.sol
  • contracts/libs/trader-pool/TraderPoolExchange.sol
  • contracts/user/UserRegistry.sol
  • contracts/libs/gov-pool/GovPoolOffchain.sol
  • contracts/gov/proposals/TokenSaleProposal.sol
  • contracts/gov/validators/GovValidators.sol
  • contracts/libs/trader-pool/TraderPoolCommission.sol
  • contracts/libs/gov-user-keeper/GovUserKeeperView.sol
  • contracts/factory/PoolRegistry.sol
  • contracts/libs/trader-pool/TraderPoolLeverage.sol
  • contracts/libs/gov-pool/GovPoolStaking.sol
  • contracts/libs/data-structures/ShrinkableArray.sol
  • contracts/libs/math/MathHelper.sol
  • contracts/trader/TraderPoolRiskyProposal.sol
  • contracts/gov/ERC721/ERC721Power.sol
  • contracts/gov/ERC721/ERC721Multiplier.sol
  • contracts/core/PriceFeed.sol
  • contracts/trader/TraderPoolInvestProposal.sol
  • contracts/trader/BasicTraderPool.sol
  • contracts/libs/gov-pool/GovPoolCreate.sol
  • contracts/insurance/Insurance.sol
  • contracts/gov/settings/GovSettings.sol
  • contracts/gov/GovPool.sol
  • contracts/gov/proposals/DistributionProposal.sol
  • contracts/libs/utils/AddressSetHelper.sol
  • contracts/libs/utils/DataHelper.sol
  • contracts/libs/price-feed/PriceFeedLocal.sol
  • contracts/trader/TraderPool.sol
  • contracts/trader/TraderPoolProposal.sol

Part of DeXe Protocol’s security record

DeXe Protocol
Audits3 audits by 2 firmsLast audit Jun 2023, by Hacken.
Critical findings2 open critical findingsReported in “DeXe / DEX” by Hacken, Jun 2023. Not marked fixed in that report.
Open DeXe Protocol’s security record

Security firm

CertiK

1,044 projects reviewed · 1,375 reports · latest May 2025

See CertiK’s audits

Something wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.

Tell us