Platform: Code4rena
Start Date: 04/05/2022
Pot Size: $50,000 DAI
Total HM: 24
Participants: 71
Period: 5 days
Judge: Justin Goro
Total Solo HM: 14
Id: 119
League: ETH
Rank: 23/71
Findings: 3
Award: $306.21
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: MaratCerby
Also found by: 0x1337, 0x52, 0xYamiDancho, AuditsAreUS, CertoraInc, Dravee, GimelSec, IllIllI, PPrieditis, Ruhum, TrungOre, VAD37, berndartmueller, cccz, csanuragjain, defsec, hickuphh3, horsefacts, hyh, jayjonah8, kenzo, leastwood, mtz, p4st13r4, reassor, throttle, wuwe1, ych18
3.1753 DAI - $3.18
https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/PermissionlessBasicPoolFactory.sol#L209 https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/PermissionlessBasicPoolFactory.sol#L180
A malicious token, or one that implemented transfer hooks, could re-enter the public calling function (such as withdraw()) before proper internal accounting was completed. Because the earned reward function looks up the pool.totalDepositsWei and pool.rewardsWeiClaimed[, which is not yet updated when the transfer occurs, it would be possible for a malicious contract to re-enter _withdraw repeatedly and drain the pool. (Tokens with hooks (ERC777 and ERC677) would allow to exploit the contract and drain it in it's entirety.)
Code Review
Consider using re-entrancy guard on all main action functions (e.g. deposit, withdraw and etc): https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol
#0 - illuzen
2022-05-12T05:55:51Z
ReentrancyGuard is not the only way to guard against re-entrance. Please read the code.
Duplicate #34
#1 - KenzoAgada
2022-06-17T15:48:46Z
Doesn't seem to be duplicate of #34 to me. 34 is about FoT tokens and checking balance before and after. This issue is a general reentrancy warning issue. There's no valid attack path outlined, and as the sponsor says, the code is protected via other means. Should probably be invalid or low severity.
#2 - gititGoro
2022-06-23T03:01:52Z
The link to 34 was made because they're both about state update issues in the presence of potential reentrancy or token state updates that disagree with the parameters. FOT is just one example of a non malicious token which is why I added that footnote comment. In both issues, there wasn't a solid attack path outlined but the sponsor was concerned with the general issue of handling unorthodox token transfers. This is why the issue remained confirmed and in fact a pull request was submitted addressing it.
🌟 Selected for report: horsefacts
Also found by: 0x1f8b, 0xYamiDancho, 0xf15ers, 0xkatana, ACai, AlleyCat, Bruhhh, Dravee, Funen, GimelSec, Hawkeye, IllIllI, MaratCerby, PPrieditis, Picodes, Ruhum, TerrierLover, VAD37, berndartmueller, csanuragjain, defsec, delfin454000, eccentricexit, ellahi, fatherOfBlocks, gzeon, hansfriese, hickuphh3, hyh, ilan, joestakey, juicy, kebabsec, oyc_109, rajatbeladiya, reassor, rfa, robee, samruna, simon135, sorrynotsorry, throttle
147.2422 DAI - $147.24
The afunctions that change critical parameters should emit events. Events allow capturing the changed parameters so that off-chain tools/interfaces can register such changes with timelocks that allow users to evaluate them and consider if they would like to engage/exit based on how they perceive the changes as affecting the trustworthiness of the protocol or profitability of the implemented financial services. The alternative of directly querying on-chain contract state for such changes is not considered practical for most users/usages.
Missing events and timelocks do not promote transparency and if such changes immediately affect users’ perception of fairness or trustworthiness, they could exit the protocol causing a reduction in liquidity which could negatively impact protocol TVL and reputation.
https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/PermissionlessBasicPoolFactory.sol#L314
See similar High-severity H03 finding OpenZeppelin’s Audit of Audius (https://blog.openzeppelin.com/audius-contracts-audit/#high) and Medium-severity M01 finding OpenZeppelin’s Audit of UMA Phase 4 (https://blog.openzeppelin.com/uma-audit-phase-4/)
None
Add events to all functions that change critical parameters.
The critical procedures should be two step process.
https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/VoterID.sol#L151
Code Review
Lack of two-step procedure for critical operations leaves them error-prone. Consider adding two step procedure on the critical functions.
In the contracts, there are multiple version of pragmas are used. Some of them is used with 0.8.12 and some of them are 0.8.9. The contracts should be deployed with the consistent pragma.
https://swcregistry.io/docs/SWC-103
All Contracts
Manual code review
Lock the pragma version: delete pragma solidity 0.8.10 in favor of pragma solidity 0.8.10.
Missing checks for zero-addresses may lead to infunctional protocol, if the variable addresses are updated incorrectly.
https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/PermissionlessBasicPoolFactory.sol#L75 https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/VoterID.sol#L108 https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/MerkleIdentity.sol#L52 https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/MerkleIdentity.sol#L60 https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/MerkleIdentity.sol#L67 https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/MerkleIdentity.sol#L75
Code Review
Consider adding zero-address checks in the discussed constructors: require(newAddr != address(0));.
During the code review, It has been observed that parameters are mistakenly named as "ooner","sywol" instead of "owner" and "symbol".
https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/VoterID.sol#L109 https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/VoterID.sol#L183
_owner_ = ooner; //owner // we set it here with no resetting allowed so we cannot commit to NFTs and then reset _minter = minter; _name = nomen; //name _symbol = symbowl; //symbol
Code Review
Consider to fix typos.
It is good to add a require() statement that checks the return value of token transfers or to use something like OpenZeppelin’s safeTransfer/safeTransferFrom unless one is sure the given token reverts in case of a failure. Failure to do so will cause silent failures of transfers and affect token accounting in contract.
Reference: This similar medium-severity finding from Consensys Diligence Audit of Fei Protocol: https://consensys.net/diligence/audits/2021/01/fei-protocol/#unchecked-return-value-for-iweth-transfer-call
Navigate to the following contract.
transfer/transferFrom functions are used instead of safe transfer/transferFrom on the following contracts.
contracts/MerkleDropFactory.sol::77 => require(IERC20(merkleTree.tokenAddress).transferFrom(msg.sender, address(this), value), "ERC20 transfer failed"); contracts/MerkleDropFactory.sol::107 => require(IERC20(tree.tokenAddress).transfer(destination, value), "ERC20 transfer failed"); contracts/MerkleResistor.sol::121 => require(IERC20(merkleTree.tokenAddress).transferFrom(msg.sender, address(this), value), "ERC20 transfer failed"); contracts/MerkleResistor.sol::204 => require(IERC20(tree.tokenAddress).transfer(destination, currentWithdrawal), 'Token transfer failed'); contracts/MerkleVesting.sol::89 => require(IERC20(merkleTree.tokenAddress).transferFrom(msg.sender, address(this), value), "ERC20 transfer failed"); contracts/MerkleVesting.sol::173 => IERC20(tree.tokenAddress).transfer(destination, currentWithdrawal); contracts/PermissionlessBasicPoolFactory.sol::144 => success = success && IERC20(pool.rewardTokens[i]).transferFrom(msg.sender, address(this), amount); contracts/PermissionlessBasicPoolFactory.sol::198 => bool success = IERC20(pool.depositToken).transferFrom(msg.sender, address(this), amount); contracts/PermissionlessBasicPoolFactory.sol::230 => success = success && IERC20(pool.rewardTokens[i]).transfer(receipt.owner, transferAmount); contracts/PermissionlessBasicPoolFactory.sol::233 => success = success && IERC20(pool.depositToken).transfer(receipt.owner, receipt.amountDepositedWei); contracts/PermissionlessBasicPoolFactory.sol::252 => success = success && IERC20(pool.rewardTokens[i]).transfer(pool.excessBeneficiary, rewards); contracts/PermissionlessBasicPoolFactory.sol::269 => success = success && IERC20(pool.rewardTokens[i]).transfer(globalBeneficiary, tax);
Code Review
Consider using safeTransfer/safeTransferFrom or require() consistently.
During the code review, It has been noticed that to the contract is missing some sanity checks in the addPool function. When adding pool, deposit and reward token can be same and that can create unfair situation on the contracts.
Navigate to the following contract.
The contract allows to be same on the deposit and reward tokens.
https://github.com/code-423n4/2022-05-factorydao/blob/main/contracts/PermissionlessBasicPoolFactory.sol#L108
Code Review
Do not allow reward tokens are same as deposit Token.
#0 - illuzen
2022-05-12T09:12:58Z
all duplicates except last, which is invalid, we explicitly support simple staking (depositToken == rewardToken)
#1 - gititGoro
2022-06-12T03:32:46Z
Very nicely documented. Bonus points awarded.
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0xNazgul, 0xYamiDancho, 0xf15ers, 0xkatana, ACai, CertoraInc, Dravee, Funen, GimelSec, Hawkeye, PPrieditis, Picodes, Ruhum, TerrierLover, Tomio, VAD37, Waze, csanuragjain, defsec, delfin454000, eccentricexit, ellahi, fatherOfBlocks, gzeon, hansfriese, horsefacts, ilan, joestakey, juicy, minhquanym, oyc_109, rajatbeladiya, reassor, rfa, robee, samruna, simon135, z3s
155.7888 DAI - $155.79
> 0 can be replaced with != 0 for gas optimization
Shortening revert strings to fit in 32 bytes will decrease deploy time gas and will decrease runtime gas when the revert condition has been met.
Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead for computing memory offset, etc.
Revert strings > 32 bytes are here:
contracts/MerkleDropFactory.sol::90 => require(treeIndex <= numTrees, "Provided merkle index doesn't exist"); contracts/MerkleDropFactory.sol::92 => require(!withdrawn[destination][treeIndex], "You have already withdrawn your entitled token."); contracts/MerkleIdentity.sol::127 => require(verifyMetadata(tree.metadataMerkleRoot, tokenId, uri, metadataProof), "The metadata proof could not be verified"); contracts/MerkleResistor.sol::171 => require(initialized[destination][treeIndex], "You must initialize your account first."); contracts/MerkleVesting.sol::141 => require(initialized[destination][treeIndex], "You must initialize your account first."); contracts/VoterID.sol::5 => import "../interfaces/IERC721Receiver.sol"; contracts/VoterID.sol::37 => // Equals to `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))` contracts/VoterID.sol::210 => /// `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`. contracts/VoterID.sol::217 => require(checkOnERC721Received(from, to, tokenId, data), "Identity: transfer to non ERC721Receiver implementer"); contracts/VoterID.sol::305 => require(owners[tokenId] == from, "Identity: Transfer of token that is not own"); contracts/VoterID.sol::306 => require(to != address(0), "Identity: transfer to the zero address");
Manual Review
Shorten the revert strings to fit in 32 bytes. That will affect gas optimization.
For the arithmetic operations that will never over/underflow, using the unchecked directive (Solidity v0.8 has default overflow/underflow checks) can save some gas from the unnecessary internal over/underflow checks.
contracts/MerkleLib.sol::22 => for (uint i = 0; i < proof.length; i += 1) { contracts/MerkleResistor.sol::40 => uint minEndTime; // minimum length (offset, not absolute) of vesting schedule in seconds contracts/MerkleResistor.sol::41 => uint maxEndTime; // maximum length (offset, not absolute) of vesting schedule in seconds contracts/MerkleResistor.sol::130 => /// @param vestingTime the actual length of the vesting schedule, chosen by the user contracts/MerkleResistor.sol::211 => /// @param vestingTime user chosen length of vesting schedule contracts/MerkleResistor.sol::243 => // x axis = length of vesting schedule contracts/PermissionlessBasicPoolFactory.sol::112 => require(rewardsWeiPerSecondPerToken.length == rewardTokenAddresses.length, 'Rewards and reward token arrays must be same length'); contracts/PermissionlessBasicPoolFactory.sol::115 => for (uint i = 0; i < rewardTokenAddresses.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::141 => for (uint i = 0; i < pool.rewardFunding.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::167 => uint[] memory rewardsLocal = new uint[](pool.rewardsWeiPerSecondPerToken.length); contracts/PermissionlessBasicPoolFactory.sol::168 => for (uint i = 0; i < pool.rewardsWeiPerSecondPerToken.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::224 => for (uint i = 0; i < rewards.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::249 => for (uint i = 0; i < pool.rewardTokens.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::266 => for (uint i = 0; i < pool.rewardTokens.length; i++) {
None
Consider applying unchecked arithmetic where overflow/underflow is not possible. Example can be seen from below.
Unchecked{i++};
Since _amount can be 0. Checking if (_amount != 0) before the transfer can potentially save an external call and the unnecessary gas cost of a 0 token transfer.
contracts/MerkleDropFactory.sol::77 => require(IERC20(merkleTree.tokenAddress).transferFrom(msg.sender, address(this), value), "ERC20 transfer failed"); contracts/MerkleDropFactory.sol::107 => require(IERC20(tree.tokenAddress).transfer(destination, value), "ERC20 transfer failed"); contracts/MerkleResistor.sol::121 => require(IERC20(merkleTree.tokenAddress).transferFrom(msg.sender, address(this), value), "ERC20 transfer failed"); contracts/MerkleResistor.sol::204 => require(IERC20(tree.tokenAddress).transfer(destination, currentWithdrawal), 'Token transfer failed'); contracts/MerkleVesting.sol::89 => require(IERC20(merkleTree.tokenAddress).transferFrom(msg.sender, address(this), value), "ERC20 transfer failed"); contracts/MerkleVesting.sol::173 => IERC20(tree.tokenAddress).transfer(destination, currentWithdrawal); contracts/PermissionlessBasicPoolFactory.sol::144 => success = success && IERC20(pool.rewardTokens[i]).transferFrom(msg.sender, address(this), amount); contracts/PermissionlessBasicPoolFactory.sol::198 => bool success = IERC20(pool.depositToken).transferFrom(msg.sender, address(this), amount); contracts/PermissionlessBasicPoolFactory.sol::230 => success = success && IERC20(pool.rewardTokens[i]).transfer(receipt.owner, transferAmount); contracts/PermissionlessBasicPoolFactory.sol::233 => success = success && IERC20(pool.depositToken).transfer(receipt.owner, receipt.amountDepositedWei); contracts/PermissionlessBasicPoolFactory.sol::252 => success = success && IERC20(pool.rewardTokens[i]).transfer(pool.excessBeneficiary, rewards); contracts/PermissionlessBasicPoolFactory.sol::269 => success = success && IERC20(pool.rewardTokens[i]).transfer(globalBeneficiary, tax);
All Contracts
None
Consider checking amount != 0.
When a variable is declared solidity assigns the default value. In case the contract assigns the value again, it costs extra gas.
Example: uint x = 0 costs more gas than uint x without having any different functionality.
contracts/MerkleLib.sol::22 => for (uint i = 0; i < proof.length; i += 1) { contracts/MerkleResistor.sol::40 => uint minEndTime; // minimum length (offset, not absolute) of vesting schedule in seconds contracts/MerkleResistor.sol::41 => uint maxEndTime; // maximum length (offset, not absolute) of vesting schedule in seconds contracts/MerkleResistor.sol::130 => /// @param vestingTime the actual length of the vesting schedule, chosen by the user contracts/MerkleResistor.sol::211 => /// @param vestingTime user chosen length of vesting schedule contracts/MerkleResistor.sol::243 => // x axis = length of vesting schedule contracts/PermissionlessBasicPoolFactory.sol::112 => require(rewardsWeiPerSecondPerToken.length == rewardTokenAddresses.length, 'Rewards and reward token arrays must be same length'); contracts/PermissionlessBasicPoolFactory.sol::115 => for (uint i = 0; i < rewardTokenAddresses.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::141 => for (uint i = 0; i < pool.rewardFunding.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::167 => uint[] memory rewardsLocal = new uint[](pool.rewardsWeiPerSecondPerToken.length); contracts/PermissionlessBasicPoolFactory.sol::168 => for (uint i = 0; i < pool.rewardsWeiPerSecondPerToken.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::224 => for (uint i = 0; i < rewards.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::249 => for (uint i = 0; i < pool.rewardTokens.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::266 => for (uint i = 0; i < pool.rewardTokens.length; i++) {
Code Review
uint x = 0 costs more gas than uint x without having any different functionality.
> 0 can be replaced with != 0 for gas optimization
!= 0
is a cheaper operation compared to > 0
, when dealing with uint. (Before Pragma 0.8.13)
contracts/FixedPricePassThruGate.sol::51 => if (msg.value > 0) { contracts/MerkleResistor.sol::156 => startTime, // start time will usually be in the past, if pctUpFront > 0 contracts/SpeedBumpPriceGate.sol::77 => if (msg.value > 0) { contracts/VoterID.sol::52 => * => 0x70a08231 ^ 0x6352211e ^ 0x095ea7b3 ^ 0x081812fc ^ contracts/VoterID.sol::208 => /// checks if `to` is a smart contract (code size > 0). If so, it calls contracts/VoterID.sol::348 => return size > 0;
Code Review
Use "!=0" instead of ">0" for the gas optimization.
Using newer compiler versions and the optimizer gives gas optimizations and additional safety checks are available for free.
All Contracts
Solidity 0.8.10 has a useful change which reduced gas costs of external calls which expect a return value: https://blog.soliditylang.org/2021/11/09/solidity-0.8.10-release-announcement/
Solidity 0.8.13 has some improvements too but not well tested.
Code Generator: Skip existence check for external contract if return data is expected. In this case, the ABI decoder will revert if the contract does not exist
All Contracts
None
Consider to upgrade pragma to at least 0.8.10.
++i is more gas efficient than i++ in loops forwarding.
contracts/PermissionlessBasicPoolFactory.sol::115 => for (uint i = 0; i < rewardTokenAddresses.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::141 => for (uint i = 0; i < pool.rewardFunding.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::168 => for (uint i = 0; i < pool.rewardsWeiPerSecondPerToken.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::224 => for (uint i = 0; i < rewards.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::249 => for (uint i = 0; i < pool.rewardTokens.length; i++) { contracts/PermissionlessBasicPoolFactory.sol::266 => for (uint i = 0; i < pool.rewardTokens.length; i++) {
Code Review
It is recommend to use unchecked{++i} and change i declaration to uint256.
Using double require instead of operator && can save more gas.
contracts/PermissionlessBasicPoolFactory.sol::144 => success = success && IERC20(pool.rewardTokens[i]).transferFrom(msg.sender, address(this), amount); contracts/PermissionlessBasicPoolFactory.sol::198 => bool success = IERC20(pool.depositToken).transferFrom(msg.sender, address(this), amount); contracts/PermissionlessBasicPoolFactory.sol::230 => success = success && IERC20(pool.rewardTokens[i]).transfer(receipt.owner, transferAmount); contracts/PermissionlessBasicPoolFactory.sol::233 => success = success && IERC20(pool.depositToken).transfer(receipt.owner, receipt.amountDepositedWei); contracts/PermissionlessBasicPoolFactory.sol::252 => success = success && IERC20(pool.rewardTokens[i]).transfer(pool.excessBeneficiary, rewards); contracts/PermissionlessBasicPoolFactory.sol::269 => success = success && IERC20(pool.rewardTokens[i]).transfer(globalBeneficiary, tax);
Code Review
Example
using &&: function check(uint x)public view{ require(x == 0 && x < 1 ); } // gas cost 21630 using double require: require(x == 0 ); require( x < 1); } } // gas cost 21622
Strict inequalities add a check of non equality which costs around 3 gas.
contracts/FixedPricePassThruGate.sol::51 => if (msg.value > 0) { contracts/MerkleResistor.sol::156 => startTime, // start time will usually be in the past, if pctUpFront > 0 contracts/SpeedBumpPriceGate.sol::77 => if (msg.value > 0) { contracts/VoterID.sol::52 => * => 0x70a08231 ^ 0x6352211e ^ 0x095ea7b3 ^ 0x081812fc ^ contracts/VoterID.sol::208 => /// checks if `to` is a smart contract (code size > 0). If so, it calls contracts/VoterID.sol::348 => return size > 0;
Code Review
Use >= or <= instead of > and < when possible.
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 give more 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.
Custom errors are defined using the error statement, which can be used inside and outside of contracts (including interfaces and libraries).
Instances include:
All require Statements
Code Review
Recommended to replace revert strings with custom errors.
#0 - illuzen
2022-05-12T09:09:20Z
all duplicates except c4-006
#1 - gititGoro
2022-06-05T00:48:35Z
C4-008 is not correctly reported: there's no need for a double require. But the principle of avoiding && will be taken into account.