CertiK reviewed Nut DeFi (Nut DeFi - Audit) on 20 Jan 2025: 33 findings, 0 critical; 18 marked fixed, 3 acknowledged, 12 open.
Report hosted on skynet.certik.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 3 | 0 |
| Medium | 6 | 0 | 8 |
| Low | 6 | 0 | 10 |
In `findRoot`, there is no limit on the depth of the upper-level inviter search: ```solidity=912 function findRoot(address user) public view returns (bool) { address current = user; while (current != address(0)) { address inviter = _userInfo[current].inviter; if (inviter == receiveAddress) { return true; } if (inviter == address(0)) { break; } current = inviter; } return false; } ``` When there are too many upper-level inviters, `findRoot` can consume a large amount of gas from the user, potentially leading to DoS vulnerability.
In the `_transfer` function, the following code contains flaws and is vulnerable to attacks: `nut_live.sol` ```solidity=545 if (lpBalance > lpAmount) { uint256 debtAmount = lpBalance - lpAmount; uint256 maxDebtAmount = _lastMaybeAddLPAmount * IERC20(mainPair).totalSupply().div(_balances[mainPair]); if (debtAmount > maxDebtAmount) { excludeLpProvider[lastMaybeAddLPAddress] = true; ``` Firstly, `maxDebtAmount` uses division before multiplication, while PancakeSwap uses multiplication before division in its calculation: `nut_live.sol` ```solidity=547 uint256 maxDebtAmount = _lastMaybeAddLPAmount * IERC20(mainPair).totalSupply().div(_balances[mainPair]); ``` Due to rounding, this may cause `maxDebtAmount` to be smaller than the actual value, incorrectly setting `lastMaybeAddLPAddress` as `excludeLpProvider`. Secondly, for any user adding liquidity, an attacker can transfer LP from the main pair (with any amount greater than 0) to the user between their liquidity addition and the next NUT transfer. This would increase the `debtAmount`, causing it to exceed `maxDebtAmount` and prevent the user from receiving staking rewards. Finally, during remove liquidity, due to calculation issues, the liquidity might be calculated as greater than the actual amount, causing `_userLPAmount[to]` to be smaller than the actual value: `nut_live.sol` ```solidity=623 if (isRemoveLP) { if (!_feeWhiteList[to]) { takeFee = true; uint256 liquidity = (amount * ISwapPair(_mainPair).totalSupply() + 1).div(balanceOf(_mainPair) - 1); if (from != address(_swapRouter)) { liquidity = (amount * ISwapPair(_mainPair).totalSupply() + 1).div(balanceOf(_mainPair) - amount - 1); } require(_userLPAmount[to] >= liquidity, ">uLP"); _userLPAmount[to] -= liquidity; } } ``` If the user adds liquidity after this, it may cause the `debtAmount` to be larger than the actual value, causing it to exceed `maxDebtAmount` and incorrectly setting `lastMaybeAddLPAddress` as `excludeLpProvider`.
In the transfer function, there are two instances where the code logic checks whether it is in the process of removing liquidity. The first instance requires that the `from` address is the main pair and the `to` address is not in the fee whitelist. At this point, if the USDT amount in the pair is less than or equal to the amount recorded in the reserves, it is considered as a remove liquidity action : `nut_live.sol` ```solidity=595 isRemoveLP = _isRemoveLiquidity(); ``` `nut_live.sol` ```solidity=988 function _isRemoveLiquidity() internal view returns (bool isRemove){ ISwapPair mainPair = ISwapPair(_mainPair); (uint r0,uint256 r1,) = mainPair.getReserves(); address tokenOther = _usdt; uint256 r; if (tokenOther < address(this)) { r = r0.div(10 ** 12); } else { r = r1.div(10 ** 12); } uint256 bal = (IERC20(tokenOther).balanceOf(_mainPair)).div(10 ** 12); isRemove = r >= bal; } ``` However, the PancakeSwap swap function allows flash loans. During a flash loan, both USDT and NUT tokens may be reduced simultaneously and transferred to the contract. The contract only needs to repay the loan and pay the fee in the `pancakeCall` to maintain the K value greater than or equal to its original value. This situation would also be recognized as remove liquidity. In this case, if the user has liquidity in the pair, the `_userLPAmount` recorded in the token contract will be reduced, thereby decreasing the reward. If not, the transaction may fail. `nut_live.sol` ```solidity=623 if (isRemoveLP) { if (!_feeWhiteList[to]) { takeFee = true; uint256 liquidity = (amount * ISwapPair(_mainPair).totalSupply() + 1).div(balanceOf(_mainPair) - 1); if (from != address(_swapRouter)) { liquidity = (amount * ISwapPair(_mainPair).totalSupply() + 1).div(balanceOf(_mainPair) - amount - 1); } require(_userLPAmount[to] >= liquidity, ">uLP"); _userLPAmount[to] -= liquidity; } } ``` Meanwhile, the transaction fee will be recognized as remove LP and will be fully destroyed. The other instance logic requires that the `from` address is `address(_swapRouter)`. However, the router will only actively transfer NUT tokens out of the contract through the `removeLiquidityETHSupportingFeeOnTransferTokens` function, which only supports ETH and other token pairs, i.e., ETH and NUT pairs. Therefore, this operation does not refer to remove liquidity for the main pair (USDT + NUT) and should not set `isRemoveLP` to true: `nut_live.sol` ```solidity=619 if (from == address(_swapRouter)) { isRemoveLP = true; } ``` `PancakeRouter.sol` ```solidity=172 function removeLiquidityETHSupportingFeeOnTransferTokens( address token, uint liquidity, uint amountTokenMin, uint amountETHMin, address to, uint deadline ) public virtual override ensure(deadline) returns (uint amountETH) { (, amountETH) = removeLiquidity( token, WETH, liquidity, amountTokenMin, amountETHMin, address(this), deadline ); TransferHelper.safeTransfer(token, to, IERC20(token).balanceOf(address(this))); IWETH(WETH).withdraw(amountETH); TransferHelper.safeTransferETH(to, amountETH); } ``` At the same time, the liquidity calculation logic for `from == address(_swapRouter)` should be modified: `nut_live.sol` ```solidity=623 if (isRemoveLP) { if (!_feeWhiteList[to]) { takeFee = true; uint256 liquidity = (amount * ISwapPair(_mainPair).totalSupply() + 1).div(balanceOf(_mainPair) - 1); if (from != address(_swapRouter)) { liquidity = (amount * ISwapPair(_mainPair).totalSupply() + 1).div(balanceOf(_mainPair) - amount - 1); } require(_userLPAmount[to] >= liquidity, ">uLP"); _userLPAmount[to] -= liquidity; } } ``` Otherwise, removing liquidity operations for the ETH-NUT pair will also be treated as operations for the main pair (USDT-ETH), reducing the user's liquidity amount(`_userLPAmount`) and causing error in the liquidity statistics within the contract.
In the `processStakingRewards` function, when calculating the reward cycle, `progressLPBlock` records the BSC block height, while `lpRewardTimeDebt` is a time interval in seconds: `nut_live.sol` ```solidity=378 uint256 public lpRewardTimeDebt = 5 days; ``` `nut_live.sol` ```solidity=830 if (progressLPBlock + lpRewardTimeDebt > block.number) { ``` Since the BSC block time is 3 seconds per block, the actual reward cycle is not 5 days but 3 * 5 = 15 days, which is three times longer than the setting time.
The `_removeLPFee` variables in the contract have no set limits, allowing fees to potentially reach 100%. If this occurs, users would receive no tokens from the contract, resulting in a complete loss of their investment.
The `autoBurnLiquidityPairTokens` function in the `NUT` contract introduces a **liquidity burn mechanism**, which reduces the circulating supply of LP tokens. While this mechanism is intended to increase scarcity and stabilize the token price, it inadvertently creates **arbitrage opportunities**, especially when `_swapPairList` includes multiple trading pairs. When `_swapPairList` includes multiple trading pairs (e.g., `NUT/USDT` and `NUT/WETH`), the arbitrage becomes potentially more profitable: 1. The attacker identifies that a burn operation is imminent on one pair (e.g., `NUT/USDT`). 2. The attacker buys `NUT` on a **different pair** (e.g., `NUT/WETH`) where the price is still unaffected by the burn. 3. The attacker sells a few `NUT` on `NUT/WETH` and burn operation will occur on `NUT/USDT`. As a result, the price of `NUT` increases in the `NUT/USDT` pair. 4. The attacker sells the purchased `NUT` tokens at the higher price to gain some profits.
Some restrictions can be bypassed by swapping tokens and removing liquidity to the router and then getting tokens from the router. When the tokens are transferred to the router, the use uses the feature of the router function ***`removeLiquidityETHSupportingFeeOnTransferTokens`***, which will send all the specified tokens in the router to a specified address. Because the router is not supposed to receive tokens for any user other than remove liquidity.
In `processStakingRewards`, after a certain time period, the function will iterate through the `lpProviders` array and settle the rewards for the addresses it encounters: `nut_live.sol` ```solidity=852 while (gasUsed < gas && iterations < shareholderCount) { if (currentLPIndex >= shareholderCount) { currentLPIndex = 0; } shareHolder = lpProviders[currentLPIndex]; (uint256 userUSDTContribution,) = getUserLiquidityContribution(shareHolder); if (!excludeLpProvider[shareHolder]) { pairBalance = mainPair.balanceOf(shareHolder); lpAmount = _userLPAmount[shareHolder]; if (lpAmount < pairBalance) { pairBalance = lpAmount; } // Only reward if USDT equivalent meets 30 USDT minimum and reward interval has passed if (userUSDTContribution >= lpHoldCondition && block.timestamp > _lastLPRewardTimes[shareHolder] + lpRewardTimeDebt) { // Calculate 0.8% daily reward in USDT, then convert to NUT using pool price uint256 dailyUsdtReward = (userUSDTContribution.mul(DAILY_REWARD_RATE)).div(1000); uint256 rewardAmountInNUT = (dailyUsdtReward.mul(rNut)).div(rUsdt); rewardAmountInNUT *= 2; // Calculate total reward for the 5-day interval rewardAmount = rewardAmountInNUT.mul(5); if (rewardAmount > 0) { _lastLPRewardTimes[shareHolder] = block.timestamp; processInviterRewards(shareHolder, rewardAmount); _rewardTransfer(shareHolder, rewardAmount); } } } gasUsed += (gasLeft - gasleft()); gasLeft = gasleft(); currentLPIndex++; iterations++; } ``` However, the `lpProviders` array can be artificially extended by minting main pair LP tokens. When the array grows too long and the gas required to iterate through it exceeds the reward gas limit, users will not receive the corresponding rewards for each cycle. This situation causes users to be unable to receive their rewards for an extended period.
When transferring between two non-fee whitelist addresses using transferFrom, if the from address has less than 100 wei remaining after the transfer, the transfer amount will be reduced so that the user is left with at least a 100 wei balance: `nut_live.sol` ```solidity=562 if (!_feeWhiteList[from] && !_feeWhiteList[to]) { uint256 maxSellAmount; uint256 remainAmount = 10 ** (_decimals - 4); uint256 balance = _balances[from]; if (balance > remainAmount) { maxSellAmount = balance - remainAmount; } if (amount > maxSellAmount) { amount = maxSellAmount; } } ``` However, the transferFrom function will still deduct the original amount of the allowance. `nut_live.sol` ```solidity=513 function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) { _transfer(sender, recipient, amount); if (_allowances[sender][msg.sender] != MAX) { _allowances[sender][msg.sender] = _allowances[sender][msg.sender] - amount; } return true; } ```
The contract logic related to `_swapPairList` is tightly coupled with the `_mainPair` (the primary liquidity pool pair, typically NUT/USDT). Specifically, the logic for **buying**, **selling**, and **fee calculations** depends heavily on whether the sender or recipient is part of `_swapPairList`. However, there is no explicit restriction or validation in the contract to ensure that `_swapPairList` only includes the `_mainPair`. This creates a potential issue if other pairs are unintentionally or maliciously added to `_swapPairList`. ```solidity=700 else if (_swapPairList[sender]) {//Buy uint256 usdtAmount = (tAmount.mul(tokenPrice())).div(10**6); require(usdtAmount <= MAX_BUY_AMOUNT, "You can only buy max 20,000 usdt"); require(block.timestamp >= lastSwapTime[recipient] + MIN_SWAP_INTERVAL, "Must wait 2 minutes between transactions"); lastSwapTime[recipient] = block.timestamp; swapFeeAmount = (tAmount.mul(_buyFundFee + _buyDevFee)).div(10000); } else if (_swapPairList[recipient]) {//Sell require(block.timestamp >= lastSwapTime[recipient] + MIN_SWAP_INTERVAL, "Must wait 2 minutes between transactions"); lastSwapTime[recipient] = block.timestamp; isSell = true; swapFeeAmount = (tAmount.mul(_sellReservePoolFee)).div(10000); destroyFeeAmount = (tAmount.mul(_sellDestroyFee)).div(10000); } ``` In the `NUT` contract, the tracked information, such as `tokenPrice` and restrictions (e.g., `MAX_BUY_AMOUNT`, `MIN_SWAP_INTERVAL`), is derived from the `_mainPair` (e.g., NUT/USDT). The logic assumes that the `_swapPairList` corresponds to the main trading pair (`_mainPair`), where price calculations and restrictions are meaningful. If additional pairs (e.g., NUT/ETH or unrelated token pairs) are added to `_swapPairList`, the logic for **buying** and **selling** may break or behave incorrectly.
Functions with names starting with `_` should be declared as `private`/`internal`.
The functions marked as 'view' or 'pure' are unnecessarily restricted by the `onlyWhiteList` modifier. These functions are designed to be read-only, meaning they do not modify the state on the blockchain. However, they are restricted so that only a specific address can call them. It's important to note that even private state variables can be read off-chain, rendering the access restriction on these functions ineffective.
After updating the relevant information of `lastMaybeAddLPAddress`, it was not reset to zero: ```solidity=551 if (lastMaybeAddLPAddress != address(0)) { uint256 lpBalance = IERC20(mainPair).balanceOf(lastMaybeAddLPAddress); if (lpBalance > 0) { uint256 lpAmount = _userLPAmount[lastMaybeAddLPAddress]; if (lpBalance > lpAmount) { _addLpProvider(lastMaybeAddLPAddress); _userLPAmount[lastMaybeAddLPAddress] = lpBalance; // NCK-22 NCK-20 _lastLPRewardTimes[lastMaybeAddLPAddress] = _getTomorrowsMidnight(); } } _userInfo[lastMaybeAddLPAddress].rewardAmount = calculateStakingRewards(lastMaybeAddLPAddress); } ``` As a result, during transfers that do not interact with the pair, the LP-related information of `lastMaybeAddLPAddress` will still be updated. If a small amount of LP tokens is transferred into `lastMaybeAddLPAddress`, it will trigger the logic inside `lpBalance > lpAmount` and update `_lastLPRewardTimes[lastMaybeAddLPAddress]` to the next natural day’s midnight.
The cited address input is missing a check that it is not `address(0)`.
The linked statements do not affect the functionality of the codebase and appear to be either leftovers from test code or older functionality.
After the `receiveAddress` transfers the corresponding LP to the user, `batchSetUserInfo` will be called by the owner to update the corresponding `rewardAmount` and `_userLPAmount`: `NuToken.sol` ```solidity=1044 function batchSetUserInfo(address[] memory addr) external onlyOwner { require(addr.length > 0, "No addresses provided"); uint256 ownerLpBalance = 0; for (uint i = 0; i < addr.length; i++) { address user = addr[i]; uint256 lpBalance = IERC20(_mainPair).balanceOf(user); _addLpProvider(user); _userLPAmount[user] = lpBalance; ownerLpBalance += lpBalance; _lastLPRewardTimes[user] = _getTomorrowsMidnight();// NCK-22 _userInfo[user].rewardAmount = calculateStakingRewards(user); } _userLPAmount[receiveAddress] = _userLPAmount[receiveAddress] - ownerLpBalance; _userInfo[receiveAddress].rewardAmount = calculateStakingRewards(receiveAddress); } ``` However, there are several situations where the updated `_userLPAmount[receiveAddress]` may not match the actual LP token balance of `receiveAddress`, such as: 1. The `addr` list contains duplicate addresses, causing the `ownerLpBalance` to be double-counted. 2. The address in `addr` already holds LP tokens before the transfer to `receiveAddress`, which results in the `ownerLpBalance` recording an inflated value. Therefore, it is recommended to update the value of `_userLPAmount[receiveAddress]` to reflect the actual amount of LP tokens held by `receiveAddress`.
After [EIP-1884](https://eips.ethereum.org/EIPS/eip-1884) was included in the Istanbul hard fork, it is not recommended to use `.transfer()` or `.send()` for transferring native tokens as these functions have a hard-coded value for gas costs making them obsolete as they are forwarding a fixed amount of gas, specifically `2300`. This can cause issues in case the linked statements are meant to be able to transfer funds to other contracts instead of EOAs.
The linked variables assigned in the constructor can be declared as `immutable`. Immutable state variables can be assigned during contract creation but will remain constant throughout the lifetime of a deployed contract. A big advantage of immutable variables is that reading them is significantly cheaper than reading from regular state variables since they will not be stored in storage.
| Address | Chain | Source code |
|---|---|---|
| 0x29fd…978d0x29fdf61b4ab5a4af38c745d4e1ef282525d4978d | Chain: BNB Chain | Source code: Verified source |
1,044 projects reviewed · 1,375 reports · latest May 2025
See CertiK’s auditsSomething 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