Platform: Code4rena
Start Date: 18/11/2021
Pot Size: $50,000 USDC
Total HM: 18
Participants: 26
Period: 7 days
Judge: leastwood
Total Solo HM: 12
Id: 54
League: ETH
Rank: 7/26
Findings: 5
Award: $1,467.49
🌟 Selected for report: 7
🚀 Solo Findings: 0
0x0x0x
Funds of locks using tokens with not reverting transferFrom
can be stolen.
Not all ERC20
tokens revert, when a transfer fail. Some tokens return false
in that case. Currently return values are not checked at 2 spots. Let's assume there is a lock using a such ERC20
token like USDT
. A malicious user with an empty wallet can buy keys for free by calling MixinPurchase.sol#purchase
. Moreover, if a refund is possible, the user can refund the key to steal funds from the Lock
. The attacker can repeat those steps to steal all tokens from the Lock (At lest iteration, the attacker send some token to the contract to allow the transfer).
Manual analysis
Use safeTransferFrom
of OZ.
#0 - 0xleastwood
2022-01-16T08:21:25Z
Duplicate of #162
322.7515 USDC - $322.75
0x0x0x
A lock manager changes keyPrice
. If the price is increased, then possible refunds are also increased and it can result in theft of funds. If the price is reduced, by doing so lock manager can reduce the refund payments and scam the users.
Manual analysis
Save keyPrice
for each NFT
#0 - julien51
2022-01-03T15:30:04Z
This is correct, but also actually the intended behavior. When changing pricing the lock managers need to e careful about refund and for example make sure they apply a cancellation fee that covers the difference in the case of price increases.
#1 - 0xleastwood
2022-01-16T05:12:26Z
As the sponsor has stated in this issue and many other issues. The lock manager is a permissioned role with the highest level of access to the underlying lock. Therefore, they are able to make whatever changes they like. It is important to note that the lock manager can also be a contract owned by a community DAO. So it is expected that the lock manager makes enforces the necessary protections that prevent abuse.
Interestingly, the lock manager would be more inclined to abuse off-chain behaviour by not granting its members any benefits. This would be a mostly invisible way to attack the lock as on-chain behaviour is easily monitored. Hence, I've come to the decision of marking all issues related to lock manager abuse as invalid.
#2 - 0xleastwood
2022-01-16T09:48:24Z
Duplicate of #53
537.9192 USDC - $537.92
0x0x0x
Users can lose extra time, when they shareKey
When timePlusFee < timeRemaining
does not hold, the fee is calculated as follow:
fee = getTransferFee(keyOwner, timeRemaining); time = timeRemaining - fee;
By doing so fee is also charged for the fee and more fee is charged than usual. So when timePlusFee = timeRemaining - 1
, you transfer more time compared to when timePlusFee = timeRemaining
. This also penalizes transferring your all of remaining time directly.
Manual analysis
Simplest approach is to directly take as input the time a user wants to remove and calculate fee of it. With current approach, mathematically you should divide timeRemaining
with 1 + fee percentage
to obtain how much time is transferred.
#0 - 0xleastwood
2022-01-16T11:44:01Z
Duplicate of #165
🌟 Selected for report: elprofesor
Also found by: 0x0x0x, harleythedog, loop, pauliax
52.2858 USDC - $52.29
0x0x0x
Since this function doesn't check whether there is an implementation for the given version number. Implementations can be updated by calling this function, which is different from the description. Moreover, updating can create an attack vector and i don't recommend this behaviour. Also it would be nice, if it checks whether publicLockLatestVersion
is incremented by one or not.
Manual analysis
Rename or add checks to make sure implementations doesn't get updated
#0 - julien51
2022-01-03T15:21:27Z
This addLockTemplate
can only be called by the Unlock owner, who is the DAO. We should expect them to not be malicious (otherwise they could just upgrade the whole contract).
Adding verifications would cost gas for no real benefit because the owner could still resubmit a new transaction to fix the previous one.
#1 - 0xleastwood
2022-01-16T11:42:24Z
Duplicate of #39
107.5838 USDC - $107.58
0x0x0x
All ERC20 token does not support approving directly and require first approving for an amount of 0. By using safeApprove
or approving 0 first, the function will have better compatability with different tokens.
Manual analysis
#0 - julien51
2022-01-03T15:14:24Z
Why is that a bug?
#1 - 0xleastwood
2022-01-16T11:45:48Z
Duplicate of #161
🌟 Selected for report: 0x0x0x
0x0x0x
setVotingDelay
, setVotingPeriod
and setQuorum
have a similar function structure. All can emit first and then change to avoid caching.
Current implementation of setVotingDelay
is as follows:
function setVotingDelay(uint256 newVotingDelay) public onlyGovernance { uint256 oldVotingDelay = _votingDelay; _votingDelay = newVotingDelay; emit VotingDelayUpdated(oldVotingDelay, newVotingDelay); }
This can be replaced with
function setVotingDelay(uint256 newVotingDelay) public onlyGovernance { emit VotingDelayUpdated(_votingDelay, newVotingDelay); _votingDelay = newVotingDelay; }
to save gas and have a shorter code.
Manual
#0 - 0xleastwood
2022-01-17T07:57:21Z
Nice find!
🌟 Selected for report: 0x0x0x
0x0x0x
Example:
for (uint i = 0; i < arr.length; i++) { //Operations not effecting the length of the array. }
Loading length of array costs gas. Therefore, the length should be cached, if the length of the array doesn't change inside the loop. Recommended implementation:
uint length = arr.length; for (uint i = 0; i < length; i++) { //Operations not effecting the length of the array. }
By doing so the length is only loaded once rather than loading it as many times as iterations (Therefore, less gas is spent).
./contracts/mixins/MixinGrantKeys.sol:29: for(uint i = 0; i < _recipients.length; i++) {
🌟 Selected for report: 0x0x0x
0x0x0x
It is better to put require statements as early as possible to avoid wasting gas.
can be replaced with following code block
address recipient = _recipients[i]; require(recipient != address(0), 'INVALID_ADDRESS'); uint expirationTimestamp = _expirationTimestamps[i]; Key storage toKey = keyByOwner[recipient]; require(expirationTimestamp > toKey.expirationTimestamp, 'ALREADY_OWNS_KEY'); address keyManager = _keyManagers[i];
Manual
#0 - 0xleastwood
2022-01-17T07:57:54Z
I think this makes logical sense.
🌟 Selected for report: 0x0x0x
0x0x0x
As mentioned in the comment this line is safe to execute. by using unchecked
gas can be saved.
https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/Unlock.sol#L313
manual
#0 - 0xleastwood
2022-01-17T07:57:40Z
Nice optimisation!
🌟 Selected for report: 0x0x0x
0x0x0x
lock
, impl
, currentVersion
and proxy
are used only once, but all of them are cached. To save gas, all of them can be directly passed.
#0 - 0xleastwood
2022-01-17T07:57:30Z
This sounds right!
🌟 Selected for report: 0x0x0x
0x0x0x
Current implementation converts addresses the bytes32
, does skip empty bytes and read where the address is. To do so + 12
is used to loop over the address. By directly converting addresses to bytes20
, gas can be saved and more gas will be saved by avoiding + 12
. By doing so the function consumes around %10 less gas.
function address2Str( address _addr ) internal pure returns(string memory) { bytes32 value = bytes20(_addr); bytes memory alphabet = '0123456789abcdef'; bytes memory str = new bytes(42); str[0] = '0'; str[1] = 'x'; for (uint i = 0; i < 20; i++) { str[2+i*2] = alphabet[uint8(value[i] >> 4)]; str[3+i*2] = alphabet[uint8(value[i] & 0x0f)]; } return string(str); }
Manual analysis
#0 - 0xleastwood
2022-01-17T07:55:04Z
Warden is correct here. Nice optimisation! Tested this in remix.
🌟 Selected for report: 0x0x0x
0x0x0x
https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/Unlock.sol#L329-L362 can be implemented cheaper, with several upgrades as follows:
if (chainId > 1) { // non mainnet: we distribute tokens using asymptotic curve between 0 and 0.5 // maxTokens = IMintableERC20(udt).balanceOf(address(this)).mul((valueInETH / grossNetworkProduct) / (2 + 2 * valueInETH / grossNetworkProduct)); uint balance = IMintableERC20(udt).balanceOf(address(this)); maxTokens = balance * valueInETH / (2 + 2 * valueInETH / grossNetworkProduct) / grossNetworkProduct; if(maxTokens > 0){ if(tokensToDistribute > maxTokens){ tokensToDistribute = maxTokens; } } if (balance > tokensToDistribute) { uint devReward = tokensToDistribute * 20 / 100; // Only distribute if there are enough tokens IMintableERC20(udt).transfer(_referrer, tokensToDistribute - devReward); IMintableERC20(udt).transfer(owner(), devReward); } } else { // Mainnet: we mint new token using log curve maxTokens = IMintableERC20(udt).totalSupply() * valueInETH / 2 / grossNetworkProduct; if(maxTokens > 0){ if(tokensToDistribute > maxTokens){ tokensToDistribute = maxTokens; } } uint devReward = tokensToDistribute * 20 / 100; IMintableERC20(udt).mint(_referrer, tokensToDistribute - devReward); IMintableERC20(udt).mint(owner(), devReward); }
This code block makes less computations, when maxTokens == 0
. Moreover balance
is cached and shared.
Manual
#0 - CloudEllie
2021-11-23T21:57:51Z
Warden noted the above code block has some mistakes, and requested it be replaced with the following:
if (chainId > 1) { // non mainnet: we distribute tokens using asymptotic curve between 0 and 0.5 // maxTokens = IMintableERC20(udt).balanceOf(address(this)).mul((valueInETH / grossNetworkProduct) / (2 + 2 * valueInETH / grossNetworkProduct)); uint balance = IMintableERC20(udt).balanceOf(address(this)); maxTokens = balance * valueInETH / (2 + 2 * valueInETH / grossNetworkProduct) / grossNetworkProduct; if(tokensToDistribute > maxTokens){ tokensToDistribute = maxTokens; } if (tokensToDistribute > 0 && balance > tokensToDistribute) { uint devReward = tokensToDistribute * 20 / 100; // Only distribute if there are enough tokens IMintableERC20(udt).transfer(_referrer, tokensToDistribute - devReward); IMintableERC20(udt).transfer(owner(), devReward); } } else { // Mainnet: we mint new token using log curve maxTokens = IMintableERC20(udt).totalSupply() * valueInETH / 2 / grossNetworkProduct; if(tokensToDistribute > maxTokens){ tokensToDistribute = maxTokens; } if (tokensToDistribute > 0){ uint devReward = tokensToDistribute * 20 / 100; IMintableERC20(udt).mint(_referrer, tokensToDistribute - devReward); IMintableERC20(udt).mint(owner(), devReward); } }