Platform: Code4rena
Start Date: 21/06/2022
Pot Size: $30,000 USDC
Total HM: 12
Participants: 96
Period: 3 days
Judge: HardlyDifficult
Total Solo HM: 5
Id: 140
League: ETH
Rank: 19/96
Findings: 2
Award: $214.09
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x29A, 0x52, 0xNazgul, 0xNineDec, 0xc0ffEE, 0xf15ers, 0xkatana, BowTiedWardens, Chom, ElKu, Funen, GalloDaSballo, JC, JMukesh, JohnSmith, Lambda, Limbooo, MadWookie, MiloTruck, Nethermind, Noah3o6, Nyamcil, Picodes, PwnedNoMore, Randyyy, RoiEvenHaim, SmartSek, StErMi, Tadashi, TerrierLover, TomJ, Tomio, Treasure-Seeker, UnusualTurtle, Varun_Verma, Wayne, Waze, _Adam, apostle0x01, asutorufos, berndartmueller, c3phas, catchup, cccz, cloudjunky, codexploder, cryptphi, defsec, delfin454000, dipp, ellahi, exd0tpy, fatherOfBlocks, hansfriese, hyh, joestakey, kebabsec, kenta, masterchief, minhquanym, naps62, oyc_109, pashov, peritoflores, reassor, rfa, robee, sach1r0, saian, sashik_eth, shenwilly, simon135, slywaters, sorrynotsorry, sseefried, unforgiven, xiaoming90, ych18, zuhaibmohd, zzzitron
112.9332 USDC - $112.93
Few vulnerabilities were found, the main concerns are with potential lock of ETH in some contracts
strings and bytes are encoded with padding when using abi.encodePacked
. This can lead to hash collision when passing the result to keccak256
Low
Instances include:
bytes memory code = abi.encodePacked(type(ProxyVault).creationCode, uint256(uint160(address(this)))); bytes32 _hash = keccak256(abi.encodePacked(bytes1(0xff), address(this), newsalt, keccak256(code)))
and here, with a string
ProxyBasket{salt: keccak256(abi.encodePacked(_curator, _mix))}(basketImplementation))
Manual Analysis
Use abi.encode()
instead.
constructors should check the address written in an immutable address variable is not the zero address
Low
Instances include:
implementation = payable(_implementation)
Manual Analysis
Add a zero address check for the immutable variables aforementioned.
ProxyBasket
and ProxyDelegate
have receive()
functions, but do not have any withdrawal function. A call to these contracts will trigger the fallback
function. But if a call sends ETH to these contracts with no msg.data
, fallback
will not be triggered, only receive()
, resulting in the ETH getting locked.
Low
receive() external payable { }
receive() external payable { }
Manual Analysis
Add require(0 == msg.value)
in receive()
or remove the function altogether.
Setters and initializers should check the input value - ie make revert if it is the zero address or zero
Low
Instances include:
_assetAddress lacks a check in initialize()
_curator lacks a check in initialize()
_newCurator lacks a check in updateCurator()
_vaultImplementation lacks a check in the constructor
_feeTo lacks a check in the constructor
_basketImplementation lacks a check in the constructor
_newBasketImplementation lacks a check in proposeNewBasketImplementation
_newFeeAddress lacks a check in proposeNewAdminFeeAddress
_newVaultImplementation lacks a check in proposeNewVaultImplementation
Manual Analysis
Add non-zero checks - address - to the setters aforementioned.
Several tokens do not revert in case of ERC20.transfer()
failure and return false. It is good practice to use safeTransfer()
from OpenZeppelin, or simply check the return value of .transfer()
Low
Instances include:
IERC20(_asset).transfer(_to, IERC20(_asset).balanceOf(address(this)))
IERC20(_assets[i]).transfer(_to, IERC20(_assets[i]).balanceOf(address(this)))
Manual Analysis
use safeTransfer()
from OpenZeppelin or check the return value of .transfer()
.
Some of the function comments are missing function parameters or returns
Non-Critical
Instances include:
@param _totalSupply
@param _totalSupply
@param _totalSupply
@param _totalSupply
@return _saleReturn
@return _buyoutBid
@param _to
@return _amtOut
@return _feeAccruedCurator
@return _proxyVault
@return _vault
Manual Analysis
Add a comment for these parameters
There are portions of commented code in some files.
Non-Critical
Instances include:
// _purchaseReturn = _buySecondaryCurve(_to, _lowerCurveDiff)
// buyoutValuationDeposit = _currentValuation - ((primaryReserveBalance - fictitiousPrimaryReserveBalance) + secondaryReserveBalance)
Manual Analysis
Remove commented code
It is best practice to use constant variables rather than literal values to make the code easier to understand and maintain.
Non-Critical
Instances include:
1e18
1e18
1e18
1e18
2**32
2**32
2**32
2**32
Manual Analysis
Define constant variables for the literal values aforementioned.
Setters should emit an event so that Dapps can detect important changes to storage
Non-Critical
Instances include:
function updateBasketImplementation
function updateNewAdminFeeAddress
function updateNewAdminFee
function updateVaultImplementation
Manual Analysis
emit an event in all setters
Some functions are missing Natspec comments
Non-Critical
Instances include:
function permit
function safeTransferETH
function onERC721Received
function onERC1155Received
function onERC1155BatchReceived
function getVaults
function createBasket
function getBasketAddress
function withdrawAdminFee
function getTwavObservations()
function INIT_EIP712()
function getChainID()
Manual Analysis
Add comments to these functions
Functions should be ordered following the Soldiity conventions: receive()
function should be placed after the constructor and before every other function.
Non-Critical
Several contracts have receive()
at the end:
NibblVault.sol
NibblVaultFactory.sol
Basket.sol
Manual Analysis
Place the receive()
functions after the constructor, before all the other functions.
Spacing is present in the vast majority of comments (// x
), but some comments do not have space.
Non-Critical
///@notice current status of vault
///@notice reenterancy guard
///@notice withdraw multiple ERC721s
Manual Analysis
Use spacings consistently in all comments.
contracts should be compiled using a fixed compiler version. Locking the pragma helps ensure that contracts do not accidentally get deployed using a different compiler version with which they have been tested the most
Non-Critical
AccessControlMechanism.sol
has a floating pragma.
Manual Analysis
Used a fixed compiler version
contracts within the scope should be compiled using the same compiler version.
Non-Critical
All the files in scope have the compiler version set to 0.8.10
, except for AccessControlMechanism.sol
which has its pragma set to ^0.8.0
.
Manual Analysis
Use the same compiler version throughout the contracts
It is good practice to mark functions as external
instead of public
if they are not called by the contract where they are defined.
Non-Critical
Instances include:
function getVaultAddress
function getVaults
function createBasket
function getBasketAddress
Manual Analysis
Declare these functions as external
instead of public
Some require statements are missing error strings, which makes it more difficult to debug when the function reverts.
Non-critical
Manual Analysis
Add error strings to all require statements.
For readability, it is best to use scientific notation (e.g 10e5
) rather than decimal literals(100000
) or exponentiation(10**5
). Underscores are used throughout the contracts and do improve readability too, so this is more of a suggestion.
Non-Critical
Instances include:
uint256 private constant SCALE = 1_000_000
Manual Analysis
It is good practice to add timelock to critical parameters changes, such as admin changes, to give users time to react.
Non-Critical
Instances include:
There is no timelock in updateCurator. If the curator were to input an incorrect address here, they would lose all the curators fees accrued.
Manual Analysis
Add a timelock to updateCurator
uint
is an alias for uint256
.
It is better to use uint256: it brings readability and consistency in the code, and it future proofs it in case of any changes to the alias of uint.
This is all the more valid as uint256
is used throughout the contracts, uint
is only used in few instances.
Non-Critical
uint _primaryReserveBalance = (primaryReserveRatio * _initialTokenSupply * _initialTokenPrice) / (SCALE * 1e18)
uint _secondaryReserveBalance = secondaryReserveBalance
uint _primaryReserveBalance = primaryReserveBalance
uint _secondaryReserveBalance = secondaryReserveBalance
uint _amount = unsettledBids[msg.sender]
Manual Analysis
replace uint
with
uint256
#0 - HardlyDifficult
2022-07-04T17:44:31Z
Lot of suggestions here. Most are pretty minor but they appear to be valid & good improvements to make.
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0x1f8b, 0x29A, 0xKitsune, 0xNazgul, 0xf15ers, 0xkatana, 8olidity, ACai, BowTiedWardens, Chandr, Chom, ElKu, Fitraldys, Funen, IgnacioB, JC, Lambda, Limbooo, MiloTruck, Noah3o6, Nyamcil, Picodes, Randyyy, SmartSek, StErMi, TerrierLover, TomJ, Tomio, UnusualTurtle, Waze, _Adam, ajtra, c3phas, cRat1st0s, catchup, codexploder, cryptphi, defsec, delfin454000, ellahi, exd0tpy, fatherOfBlocks, hansfriese, joestakey, kebabsec, kenta, m_Rassska, minhquanym, oyc_109, pashov, reassor, rfa, robee, sach1r0, saian, sashik_eth, simon135, slywaters, ych18, ynnad, zuhaibmohd
101.1556 USDC - $101.16
Anytime you are reading from storage more than once, it is cheaper in gas cost to cache the variable in memory: a SLOAD cost 100gas, while MLOAD and MSTORE cost 3 gas.
In particular, in for
loops, when using the length of a storage array as the condition being checked after each loop, caching the array length in memory can yield significant gas savings if the array length is high
Instances include:
scope: _updateTWAV()
twavObservationsIndex
is read 3 timesManual Analysis
cache these storage variables in memory
If a reference type function parameter is read-only, it is cheaper in gas to use calldata instead of memory. Calldata is a non-modifiable, non-persistent area where function arguments are stored, and behaves mostly like memory.
Try to use calldata as a data location because it will avoid copies and also makes sure that the data cannot be modified.
Instances include:
address[] memory _assetAddresses, uint256[] memory _assetIDs
address[] memory _assets
address[] memory _assets, uint256[] memory _assetIDs
Manual Analysis
Replace memory
with calldata
In the EVM, there is no opcode for >=
or <=
.
When using greater than or equal, two operations are performed: >
and =
.
Using strict comparison operators hence saves gas, approximately 20
gas in require
and if
statements
Instances include:
buyoutEndTime <= block.timestamp
_secondaryReserveRatio <= primaryReserveRatio
_secondaryReserveRatio >= MIN_SECONDARY_RESERVE_RATIO
_totalSupply >= _initialTokenSupply
_lowerCurveDiff >= msg.value
_minAmtOut <= _purchaseReturn
_secondaryReserveBalance - _saleReturn >= MIN_SECONDARY_RESERVE_BALANCE, "NibblVault: Excess sell"
(_initialTokenSupply + _amtIn) <= _totalSupply
_saleReturn >= _minAmtOut, "NibblVault: Return too low"
block.timestamp >= minBuyoutTime, "NibblVault: minBuyoutTime < now"
_buyoutBid >= _currentValuation, "NibblVault: Bid too low"
_twav >= buyoutRejectionValuation
block.timestamp <= deadline
msg.value >= MIN_INITIAL_RESERVE_BALANCE
block.timestamp >= basketUpdateTime
block.timestamp >= feeToUpdateTime
_newFee <= MAX_ADMIN_FEE
block.timestamp >= feeAdminUpdateTime
block.timestamp >= vaultUpdateTime
Manual Analysis
Replace <=
with <
, and >=
with >
. Do not forget to increment/decrement the compared variable
example:
-block.timestamp >= basketUpdateTime +block.timestamp > basketUpdateTime - 1;
However, if 1
is negligible compared to the value of the variable, we can omit the increment.
-block.timestamp >= basketUpdateTime +block.timestamp > basketUpdateTime;
Constant expressions are re-calculated each time it is in use, costing an extra 97
gas than a constant every time they are called.
Instances include:
bytes32 public constant FEE_ROLE = keccak256("FEE_ROLE")
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE")
bytes32 public constant IMPLEMENTER_ROLE = keccak256("IMPLEMENTER_ROLE")
Manual Analysis
Mark these as immutable
instead of constant
Constructor parameters are expensive. The contract deployment will be cheaper in gas if they are hard coded instead of using constructor parameters.
Instances include:
vaultImplementation = _vaultImplementation
feeTo = _feeTo
basketImplementation = _basketImplementation
implementation = payable(_implementation)
Manual Analysis
Hardcode storage variables with their initial value instead of writing it during contract deployment with constructor parameters.
Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met) while providing the same amount of information, as explained here.
It not only saves gas upon deployment - ~5500
gas saved per custom error instead of a require statement, but it is also cheaper in a function call, 22
gas saved per require statement replaced with a custom error.
Custom errors are defined using the error statement
Instances include:
require(unlocked == 1, 'NibblVault: LOCKED')
require(buyoutEndTime > block.timestamp || buyoutEndTime == 0,'NibblVault: Bought Out'))
require(status == Status.buyout, "NibblVault: status != buyout")
require(buyoutEndTime <= block.timestamp, "NibblVault: buyoutEndTime <= now")
require(!NibblVaultFactory(factory).paused(), 'NibblVault: Paused')
require(_secondaryReserveRatio <= primaryReserveRatio, "NibblVault: Excess initial funds")
require(_secondaryReserveRatio >= MIN_SECONDARY_RESERVE_RATIO, "NibblVault: secResRatio too low")
require(_minAmtOut <= _purchaseReturn, "NibblVault: Return too low")
require(_secondaryReserveBalance - _saleReturn >= MIN_SECONDARY_RESERVE_BALANCE, "NibblVault: Excess sell")
require(_saleReturn >= _minAmtOut, "NibblVault: Return too low")
require(block.timestamp >= minBuyoutTime, "NibblVault: minBuyoutTime < now")
require(status == Status.initialized, "NibblVault: Status!=initialized")
require(_buyoutBid >= _currentValuation, "NibblVault: Bid too low")
require(status == Status.buyout, "NibblVault: Status!=Buyout")
require(msg.sender == curator,"NibblVault: Only Curator")
require(msg.sender == curator,"NibblVault: Only Curator")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
require(block.timestamp <= deadline, "NibblVault: expired deadline")
require(signer == owner, "NibblVault: invalid signature")
require(success, "NibblVault: ETH transfer failed")
require(msg.value >= MIN_INITIAL_RESERVE_BALANCE, "NibblVaultFactory: Initial reserve balance too low")
require(IERC721(_assetAddress).ownerOf(_assetTokenID) == msg.sender, "NibblVaultFactory: Invalid sender")
require(basketUpdateTime != 0 && block.timestamp >= basketUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(_success)
require(feeToUpdateTime != 0 && block.timestamp >= feeToUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(_newFee <= MAX_ADMIN_FEE, "NibblVaultFactory: Fee value greater than MAX_ADMIN_FEE")
require(feeAdminUpdateTime != 0 && block.timestamp >= feeAdminUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(vaultUpdateTime != 0 && block.timestamp >= vaultUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(pendingRoles[_role][msg.sender], "AccessControl: Role not pending")
Manual Analysis
Replace require and revert statements with custom errors.
For instance, in NibbleVault.sol
:
Replace
require(unlocked == 1, 'NibblVault: LOCKED')
with
if (unlocked != 1) { revert NibblVaultLocked(); }
and define the custom error in the contract
error NibblVaultLocked();
Here are the deployment costs comparison between:
the original NibbleVault
contract
······································| NibblVault · - · - · 7045960 │
the same NibbleVault
contract with one require statement replaced with a custom error:
·············· | NibblVault · - · - · 7036463 │
If a variable is not set/initialized, it is assumed to have the default value (0, false, 0x0 etc depending on the data type).
Explicitly initializing it with its default value is an anti-pattern and wastes 3
gas per variable initialized.
Instances include:
uint i = 0
uint i = 0
uint i = 0
Manual Analysis
Remove explicit initialization for default values.
If a variable is set in the constructor and never modified afterwrds, marking it as immutable
can save a storage operation - 20,000
gas.
Instances include:
uint256 public UPDATE_TIME = 2 days
Note: the contract is technically not in scope, but as NibblVaultFactory
, which is in scope, inherits from NibblVaultFactoryData
, I consider it a valid optimization.
Manual Analysis
Mark these variables as immutable
.
X += Y costs 22
more gas than X = X + Y.
Instances include:
feeAccruedCurator += _feeCurator
secondaryReserveBalance += _feeCurve
feeAccruedCurator += _feeCurator
secondaryReserveBalance += _lowerCurveDiff
_purchaseReturn += _buyPrimaryCurve(msg.value - _lowerCurveDiff, _totalSupply + _purchaseReturn)
primaryReserveBalance -= _saleReturn
_saleReturn += _sellSecondaryCurve(_amtIn - _tokensPrimaryCurve, _initialTokenSupply)
unsettledBids[bidder] += _buyoutValuationDeposit
totalUnsettledBids += _buyoutValuationDeposit
totalUnsettledBids -= _amount
Manual Analysis
use X = X + Y
instead of X += Y
(same with -
)
When a require
statement is use multiple times, it is cheaper to use a modifier instead.
Instances include:
require(msg.sender == curator,"NibblVault: Only Curator")
require(msg.sender == curator,"NibblVault: Only Curator")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
require(msg.sender == bidder,"NibblVault: Only winner")
Manual Analysis
Use modifiers for these repeated statements
Prefix increments are cheaper than postfix increments: it returns the incremented variable instead of returning a temporary variable storing the initial value of the variable. It saves 5
gas per iteration
Instances include:
Manual Analysis
change variable++
to ++variable
.
Require statements including conditions with the &&
operator can be broken down in multiple require statements to save gas.
Instances include:
require(basketUpdateTime != 0 && block.timestamp >= basketUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(feeToUpdateTime != 0 && block.timestamp >= feeToUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(feeAdminUpdateTime != 0 && block.timestamp >= feeAdminUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(vaultUpdateTime != 0 && block.timestamp >= vaultUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
Manual Analysis
Break down the statements in multiple require statements.
-require(basketUpdateTime != 0 && block.timestamp >= basketUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed") +require(basketUpdateTime != 0) +require(block.timestamp >= basketUpdateTime)
You can also improve gas savings by using custom errors
Revert strings cost more gas to deploy if the string is larger than 32 bytes. Each string exceeding that 32-byte size adds an extra 9,500
gas upon deployment.
Revert strings exceeding 32 bytes include:
require(msg.value >= MIN_INITIAL_RESERVE_BALANCE, "NibblVaultFactory: Initial reserve balance too low")
require(IERC721(_assetAddress).ownerOf(_assetTokenID) == msg.sender, "NibblVaultFactory: Invalid sender")
require(basketUpdateTime != 0 && block.timestamp >= basketUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(feeToUpdateTime != 0 && block.timestamp >= feeToUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(_newFee <= MAX_ADMIN_FEE, "NibblVaultFactory: Fee value greater than MAX_ADMIN_FEE")
require(feeAdminUpdateTime != 0 && block.timestamp >= feeAdminUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
require(vaultUpdateTime != 0 && block.timestamp >= vaultUpdateTime, "NibblVaultFactory: UPDATE_TIME has not passed")
Manual Analysis
Write the error strings so that they do not exceed 32 bytes. For further gas savings, consider also using custom errors.
The default "checked" behavior costs more gas when adding/diving/multiplying, because under-the-hood those checks are implemented as a series of opcodes that, prior to performing the actual arithmetic, check for under/overflow and revert if it is detected.
if it can statically be determined there is no possible way for your arithmetic to under/overflow (such as a condition in an if statement), surrounding the arithmetic in an unchecked
block will save gas
Instances include:
Manual Analysis
Place the arithmetic operations in an unchecked
block
Unused imports should be removed as they waste gas upon deployment
Instances include:
import { SafeMath } from "@openzeppelin/contracts/utils/math/SafeMath.sol";
Manual Analysis
Remove the SafeMath
import.