Platform: Code4rena
Start Date: 04/11/2021
Pot Size: $50,000 USDC
Total HM: 20
Participants: 28
Period: 7 days
Judge: 0xean
Total Solo HM: 11
Id: 51
League: ETH
Rank: 4/28
Findings: 8
Award: $3,800.85
🌟 Selected for report: 11
🚀 Solo Findings: 1
🌟 Selected for report: gpersoon
Also found by: elprofesor, fr0zn, pauliax
pauliax
When checking if a user is already validated, it relies on the amount to be 0. However, this check can be bypassed by claiming all your airdrop to reduce your amount to 0 and then validating yourself again to refill your allocation.
function claim prevents that by having this assertion: assert(airdrop[msg.sender].amount - claimable != 0); however, this check is missing in claimExact so you can use it to exploit the system.
A solution could be to simply not to rely on the amount but require that validated[msg.sender] == 0.
#0 - chickenpie347
2022-01-04T02:26:58Z
Duplicate of #101
pauliax
Vest function can be accessed by anyone. It accepts arbitrary _beneficiary and pushes new vesting to the array of this beneficiary timelocks. As a malicious actor I can block any user by just invoking vest function with a tiny amount of vest token. The problem may arise later when it iterates over all the timelocks in a loop. This timelocks array does not have any boundaries so it eventually can grow so large as to make operations of the contract cost too much gas to fit in a block. The execution may exceed the block gas limit, consume all the gas provided, and fail.
A simple solution would be to specify the exact timelock id (or an array of ids) when claiming or revoking the timelock. Another possible solution is to add auth restrictions on the vest function but it adds extra dependency trust on admins.
#0 - chickenpie347
2022-01-04T02:23:24Z
Duplicate of #120
pauliax
Contracts (e.g. InvestorDistribution, AirdropDistribution, Vesting) have declared to use safe ERC20 library: using SafeERC20 for IERC20;
However, when actually making the approvals or transfers, they make no use of this library and rely on simple standard functions, e.g.: IERC20 public mainToken; mainToken.approve(address(vestLock), 2**256-1); mainToken.transfer(msg.sender, claimable_to_send); vestingToken.transferFrom(msg.sender, address(this), _amount);
A similar issue was reported in a previous contest and was assigned a severity of low: https://github.com/code-423n4/2021-06-realitycards-findings/issues/105 https://github.com/code-423n4/2021-09-bvecvx-findings/issues/33
Although you probably trust the implementation of this mainToken but for extra precaution, you can use this safe library nevertheless or remove the declaration to remove the confusion otherwise. Also, please note that safeApprove is deprecated in favor of safeIncreaseAllowance and safeDecreaseAllowance: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/566a774222707e424896c0c390a84dc3c13bdcb2/contracts/token/ERC20/utils/SafeERC20.sol#L38
#0 - chickenpie347
2022-01-04T02:22:15Z
Duplicate of #31
🌟 Selected for report: pauliax
872.285 USDC - $872.28
pauliax
Public sale has a constraint that for the first 4 weeks only NFT holders can access the sale: if (currentEra < firstPublicEra) { require(nft.balanceOf(msg.sender) > 0, "You need NFT to participate in the sale."); }
However, this check can be easily bypassed with the help of flash loans. You can borrow the NFT, participate in the sale and then return this NFT in one transaction. It takes only 1 NFT that could be flashloaned again and again to give access to the sale for everyone (burnEtherForMember).
I am not sure what could be the most elegant solution to this problem. You may consider transferring and locking this NFT for at least 1 block but then the user will need to do an extra tx to retrieve it back. You may consider taking a snapshot of user balances so the same NFT can be used by one address only but then this NFT will lose its extra benefit of selling it during the pre-sale when it acts as a pre-sale token. You may consider checking that the caller is EOA but again there are ways to bypass that too.
pauliax
function vest has a parameter _isRevocable that is tied to the account address of _beneficiary. because anyone can call vest, it allows overriding benRevocable as many times as you want.
I see several potential problems with this:
One solution could be to tie revocable to the timelock, not the account so every individual vesting can or can't be revoked later. Another solution would be to introduce an admin-only function that can set the revocable status of the user. There are many more possible solutions, it is up to you to decide which one is the most appropriate in your case.
#0 - chickenpie347
2022-01-04T02:23:53Z
Duplicate of #132
pauliax
function setAdmin allows the current admin to change it to a different address. If accidentally an invalid address is used for which they do not have the private key, then it cannot be corrected and none of the functions that require admin caller can be executed. A similar issue was reported in a previous contest and was assigned a severity of medium: https://github.com/code-423n4/2021-06-realitycards-findings/issues/105
Consider either introducing a two-step process or making a test call to the new admin before updating it.
#0 - chickenpie347
2021-11-16T14:09:09Z
Addressed in #90
#1 - CloudEllie
2022-01-05T02:17:16Z
Looks like #35 is the primary here.
🌟 Selected for report: TomFrenchBlockchain
Also found by: pauliax
130.8427 USDC - $130.84
pauliax
contract Vesting defines unixYear as this: uint256 private unixYear = 52 * 7 * 24 * 60 * 60; This is equivalent to 52 weeks or 364 days but to be precise 1 year is always a bit longer, sometimes 52 weeks 1 day (non-leap year), sometimes 52 weeks, and 2 days (leap year), so you lose around 1.25 days of precision here. Also, similar issues exist when defining time constants in other contracts.
I have 2 suggestions here:
#0 - 0xean
2022-01-08T03:42:25Z
duplicate of #82
130.8427 USDC - $130.84
pauliax
Airdrop distribution declares the supply as this: uint256 private airdrop_supply = 20160000 * 10 ** 18; //Total Allocation for Airdrop
However, if you sum up all the airdropBalances, you get 20159997, so 3 tokens are missing somewhere. While this does not cause any serious issue, it makes the calculations a little bit inaccurate when calculating fractions as user percentages will be smaller than they actually should and some tokens will be left in the contract afterward.
Consider improving the precision of airdrop_supply or airdropBalances to match between.
#0 - 0xean
2022-01-08T22:11:26Z
dulplicate of #99
🌟 Selected for report: pauliax
290.7617 USDC - $290.76
pauliax
There is no reason for the function vest to be 'payable' as it does not handle ether in any way and there is no way to rescue it later in case someone accidentally sends it.
Remove 'payable' from the vest function.
🌟 Selected for report: pauliax
290.7617 USDC - $290.76
pauliax
function _recordBurn should validate that _eth > 0. Now it is possible to spam this function with 0 eth burns and fictitiously increase member statistics. I have previously reported this issue in a Vader's contest. You can read find details here: https://github.com/code-423n4/2021-04-vader-findings/issues/269
Handle case when _eth = 0 in function _recordBurn.
130.8427 USDC - $130.84
pauliax
Contracts use assert() instead of require() in multiple places. Assert is recommended to be used to check for internal errors, or to check invariants. In your case, I think these validations could better use 'require' as they are likely to be triggered: assert(claimable > 0); assert(airdrop[msg.sender].amount - claimable != 0); assert(block.timestamp - startEpochTime <= RATE_TIME); assert(block.timestamp - initTime >= YEAR * 5);
A similar issue was submitted in a previous contest and was assigned a severity of low: https://github.com/code-423n4/2021-06-realitycards-findings/issues/83
Consider replacing 'assert' with 'require' in the cases mentioned above.
🌟 Selected for report: pauliax
290.7617 USDC - $290.76
pauliax
function burnEtherForMember should validate that the address of the member is not empty (0x0) to prevent accidental burns.
When adding an investor distribution (function addInvestor) should validate that the total amount is not above the investors_supply. but then you also need to store the total amount that is already assigned to investors.
function modifyInvestor should validate that _investor != _new, otherwise it will delete the investor unless this is an expected feature.
function claimExact should validate that _value > 0 to prevent useless claims.
pauliax
You do not need to check that uints are >= 0, as they can never go negative.
require(a >= 0 && a <= SwapUtils.MAX_A, "a not within the limits"); require(a2 >= 0 && a2 <= SwapUtils.MAX_A, "a2 not within the limits"); require( futureA >= 0 && futureA <= MAX_A, "futureA must be >= 0 and <= MAX_A" ); require( futureA2 >= 0 && futureA2 <= MAX_A, "futureA2 must be >= 0 and <= MAX_A" );
#0 - chickenpie347
2022-01-04T03:02:53Z
Duplicate of #133
🌟 Selected for report: Meta0xNull
12.1196 USDC - $12.12
pauliax
function validate is very expensive as it iterates over all the airdropArray. You can add an index parameter that can be calculated off-chain to help skip this semi-useless iteration for every user.
#0 - chickenpie347
2022-01-04T02:57:23Z
Duplicate of #195
🌟 Selected for report: TomFrenchBlockchain
Also found by: PranavG, Reigada, WatchPug, jah, nathaniel, pants, pauliax, pmerkleplant
pauliax
'immutable' greatly reduces gas costs. There are variables that do not change so they can be marked as immutable/constant to greatly improve the gast costs. Examples of such variables are: IERC20 public vestingToken; address public multiSig; uint256 private unixYear = 52 * 7 * 24 * 60 * 60;
uint256 private airdrop_supply = 20160000 * 10 ** 18;
uint256 public initTime;
address deployer; address payable burnAddress;
uint public genesis; uint public daysPerEra; uint public firstPublicEra; uint public totalSupply; uint public initialDayEmission;
address tgeContract;
#0 - chickenpie347
2022-01-04T02:53:04Z
Duplicate of #1 , #3 , #102 , #249
pauliax
Here, I see at least 2 possible improvements: require(_pooledTokens.length == 2, "_pooledTokens.length must be 2 in length"); require(decimals.length == 2, "decimals.length must be 2 in length"); require( _pooledTokens.length == decimals.length, "_pooledTokens decimals mismatch" ); for (uint8 i = 0; i < _pooledTokens.length; i++)
First, the require of _pooledTokens.length == decimals.length is not necessary as it was already checked that both of these arrays are of length 2. Second, the loop end index can be hardcoded to 2 as again we already know that from the checks above. It would be nice if you extract this hardcoded value of 2 to a constant in case you decide to change it later.
#0 - chickenpie347
2022-01-04T03:02:32Z
Duplicate of #241
pauliax
function claim (and probably claimExact) does not need this check: require(msg.sender != address(0)); It is already checked when validating the user (function validate) and not validated users cannot claim.
#0 - chickenpie347
2022-01-04T02:28:40Z
Duplicate of #218
pauliax
Using the unchecked keyword to avoid redundant arithmetic underflow/overflow checks to save gas when an underflow/overflow cannot happen. We can apply the unchecked keyword in the following lines of code since there are require statements before to ensure the arithmetic operations would not cause an integer underflow or overflow:
airdrop[msg.sender].amount -= claimable; // because assert(airdrop[msg.sender].amount - claimable != 0); uint256 claimable_not_yet_vested = claimable - claimable_to_send;
require(airdrop[msg.sender].amount >= claimable); require(_value <= claimable); airdrop[msg.sender].amount -= _value;
mainToken.approve(address(vestLock), 2**256-1);
Carefully review all the operations and consider using the 'unchecked' keyword where it is safe to save gas.
#0 - chickenpie347
2022-01-04T02:59:15Z
Duplicate of #261
12.1196 USDC - $12.12
pauliax
functions available_supply() and _available_supply() are identical, the former even has a misleading comment that it is an internal function but it is actually private. Nevertheless, these functions are duplicates and this makes it harder to maintain in case you want to change the calculation logic later so you should consider re-using them.
#0 - chickenpie347
2022-01-04T02:57:54Z
Duplicate of #96
pauliax
No need to set revocable to false as it is a default value that will be set anyway: if(_isRevocable == 0){ benRevocable[_beneficiary] = [false,false]; } else if(_isRevocable == 1){ benRevocable[_beneficiary] = [true,false]; }
Proposed improvement: if (_isRevocable == 1) { benRevocable[_beneficiary] = [true, false]; }
#0 - chickenpie347
2022-01-04T02:29:46Z
Duplicate of #5
pauliax
contracts AirdropDistribution, InvestorDistribution, PublicSale, and Vesting are using SafeMath library even though they declare a pragma of version >0.8 which brings safe math by default. Using SafeMath operations is redundant in such a case.
#0 - chickenpie347
2022-01-04T03:00:18Z
Duplicate of #7 , #29 , #91
20.1994 USDC - $20.20
pauliax
There are places where the same storage variable is accessed multiple times in the same function. It would be more gas efficient to cache these variables and re-use them where necessary. E.g. function modifyInvestor accesses investors[_investor] 6 times. function _claimableAmount reads from timelocks[_addr] several times during the iterations but because this function does not modify the timelocks[_addr], it can cache this struct before executing the loop and only read cached values inside. Or here it reads the same value from storage 2 times: if (airdrop[msg.sender].claimed != 0) { claimable -= airdrop[msg.sender].claimed; } This is a waste of precious gas.
#0 - 0xean
2022-01-08T23:29:30Z
duplicate of #9
🌟 Selected for report: pauliax
44.8876 USDC - $44.89
pauliax
This check in function modifyInvestor is not neccessary: require(_investor != address(0), "Invalid old address");
as empty address cannot be added in function addInvestor and later this check will fail: require(investors[_investor].amount != 0);
🌟 Selected for report: pauliax
44.8876 USDC - $44.89
pauliax
function claim can save gas and eliminate duplicate storage access and math operations by caching claimableAmount and re-using it later when setting the benClaimed.
before: uint256 amount = _claimableAmount(msg.sender).sub(benClaimed[msg.sender]); ... benClaimed[msg.sender] = benClaimed[msg.sender].add(amount);
after: uint256 claimableAmount = _claimableAmount(msg.sender); uint256 amount = claimableAmount.sub(benClaimed[msg.sender]); ... benClaimed[msg.sender] = claimableAmount;
Also, it looks strange that in function revoke the amount is checked with 'assert': assert(amount <= benTotal[_addr]); but in function claim 'require' is used: require(amount <= benTotal[msg.sender], "Cannot withdraw more than total vested amount");
In both places probably 'assert' should be used as it is checking a scenario that should never happen under normal circumstances.
🌟 Selected for report: pauliax
44.8876 USDC - $44.89
pauliax
When revoking the user, there is no need to iterrate over all his timelocks again and calculate the total amount as it should already be stored in a benTotal[_addr] mapping: uint256 locked = 0; for (uint256 i = 0; i < timelocks[_addr].length; i++) { locked = locked.add(timelocks[_addr][i].amount); }
🌟 Selected for report: pauliax
44.8876 USDC - $44.89
pauliax
Member total_tokens in both structs Airdrop and Investors is practically not used and is a duplicate of the amount so you can remove it to save some storage. Also, gas efficiency can be improved by tightly packing the struct. Struct variables are stored in 32 bytes each so you can group smaller types to occupy less storage, e.g. airdropBalances which are later translated to the amount in Airdrop struct (10**18) can be stored in a smaller version of uint as we know all the exact values at compile time.
🌟 Selected for report: pauliax
44.8876 USDC - $44.89
pauliax
functions validate and modifyInvestor do not need nonReentrant modifier as they do not execute any external calls where you can hook up to re-enter.
🌟 Selected for report: pauliax
44.8876 USDC - $44.89
pauliax
function _recordBurn does not really need this parameter of address _payer as it is always equal to msg.sender.
Consider replacing: function _recordBurn(address _payer, ... emit Burn(_payer, ...
with: function _recordBurn(... emit Burn(msg.sender, ...
🌟 Selected for report: pmerkleplant
12.1196 USDC - $12.12
pauliax
There are some variables that are never used. Remove them to save some gas or use where they were intended. Examples are: mapping(address => uint) private _balances; mapping(address => mapping(address => uint)) private _allowances;
uint public constant firstEra = 1;
uint256 constant HOUR = 3600; uint256 constant DAY = 86400;
#0 - chickenpie347
2022-01-04T02:55:06Z
Duplicate of #161
12.1196 USDC - $12.12
pauliax
Normally there's no benefit to using these sub-types because Solidity reserves 256 bits of storage regardless of the uint size. E.g. using uint8 does not give any efficiency, actually, it is the opposite as EVM operates on default of 256-bit values so uint8 is more expensive in this case as it needs a conversion. It only gives improvements in cases where you can pack variables together, e.g. structs. There are many places where you use e.g. uint8, a few concrete examples: mapping(address => uint8) private tokenIndexes; for (uint8 i = 0; i < _pooledTokens.length; i++) function getTokenBalance(uint8 index) uint8 public constant POOL_PRECISION_DECIMALS = 18;
#0 - chickenpie347
2022-01-04T03:01:56Z
Duplicate of #175