Platform: Code4rena
Start Date: 11/11/2021
Pot Size: $50,000 USDC
Total HM: 9
Participants: 27
Period: 7 days
Judge: alcueca
Total Solo HM: 5
Id: 53
League: ETH
Rank: 3/27
Findings: 5
Award: $6,092.37
🌟 Selected for report: 7
🚀 Solo Findings: 1
WatchPug
When an operator
is removed and rebuildCache()
is called, isResolverCached()
should return true
. It returns false
in the current implemenbtation.
/// @notice Check the state of addressCache function isResolverCached() external view returns (bool) { bytes32[] memory requiredAddresses = resolverAddressesRequired(); for (uint256 i = 0; i < requiredAddresses.length; i++) { bytes32 name = requiredAddresses[i]; // false if our cache is invalid or if the resolver doesn't have the required address if (resolver.getAddress(name) != addressCache[name] || addressCache[name] == address(0)) { return false; } } return true; }
Beacuse when an operator
is removed, requiredAddresses
will includes empty items, so that addressCache[name]
will be address(0)
, and return false
.
Change to:
/// @notice Check the state of addressCache function isResolverCached() external view returns (bool) { bytes32[] memory requiredAddresses = resolverAddressesRequired(); for (uint256 i = 0; i < requiredAddresses.length; i++) { bytes32 name = requiredAddresses[i]; // false if our cache is invalid or if the resolver doesn't have the required address if (resolver.getAddress(name) != addressCache[name]) { return false; } } return true; }
#0 - maximebrugel
2021-11-22T21:38:35Z
Same issue as #139 but not pointing removeOperator
.
The issue will be fixed in #58, so i see it as a replicate.
#1 - alcueca
2021-12-03T11:07:52Z
Duplicate of #139, then
🌟 Selected for report: WatchPug
2599.8256 USDC - $2,599.83
WatchPug
When executing orders, the actual amountSpent + feesAmount
can be lower than _inputTokenAmount
, the unspent amount should be returned to the user.
However, in the current implementation, the unspent amount will be taken as part of the fee.
function _submitInOrders( uint256 _nftId, IERC20 _inputToken, uint256 _inputTokenAmount, Order[] calldata _orders, bool _reserved, bool _fromReserve ) private returns (uint256 feesAmount, IERC20 tokenSold) { _inputToken = _transferInputTokens(_nftId, _inputToken, _inputTokenAmount, _fromReserve); uint256 amountSpent; for (uint256 i = 0; i < _orders.length; i++) { amountSpent += _submitOrder(address(_inputToken), _orders[i].token, _nftId, _orders[i], _reserved); } feesAmount = _calculateFees(_msgSender(), amountSpent); assert(amountSpent <= _inputTokenAmount - feesAmount); // overspent // If input is from the reserve, update the records if (_fromReserve) { _decreaseHoldingAmount(_nftId, address(_inputToken), _inputTokenAmount); } _handleUnderSpending(_inputTokenAmount - feesAmount, amountSpent, _inputToken); tokenSold = _inputToken; }
Change to:
function _submitInOrders( uint256 _nftId, IERC20 _inputToken, uint256 _inputTokenAmount, Order[] calldata _orders, bool _reserved, bool _fromReserve ) private returns (uint256 feesAmount, IERC20 tokenSold) { _inputToken = _transferInputTokens(_nftId, _inputToken, _inputTokenAmount, _fromReserve); uint256 amountSpent; for (uint256 i = 0; i < _orders.length; i++) { amountSpent += _submitOrder(address(_inputToken), _orders[i].token, _nftId, _orders[i], _reserved); } feesAmount = _calculateFees(_msgSender(), amountSpent); assert(amountSpent <= _inputTokenAmount - feesAmount); // overspent // If input is from the reserve, update the records if (_fromReserve) { _decreaseHoldingAmount(_nftId, address(_inputToken), amountSpent+feesAmount); } ExchangeHelpers.setMaxAllowance(_token, address(feeSplitter)); feeSplitter.sendFees(_token, feesAmount); if (_inputTokenAmount > amountSpent + feesAmount) { _inputToken.transfer(_fromReserve ? address(reserve) : _msgSender(), _inputTokenAmount - amountSpent - feesAmount); } tokenSold = _inputToken; }
#0 - adrien-supizet
2021-11-23T14:48:50Z
We don't consider this an issue as this was done on purpose. We wanted to treat the positive slippage as regular fees. Most times, the dust of positive slippage will cost more to the user if they are transferred rather than passed along fees.
We made it possible for us to retrieve overcharged amounts in case of mistakes to give them back to users.
But for the sake of transparency, and in the spirit of DeFi, we have reviewed the business model of the protocol and decided to transfer back any amount that was unspent and which exceeds the 1% fixed fee.
Selecting "disputed" for now but I'll let a judge review if this should be included in the report, and if the severity was correct, as we were able to give back tokens to users if they made a mistake calling the protocol.
#1 - alcueca
2021-12-03T15:23:05Z
If stated in the README or comments, the issue would be invalid. The sponsor can choose whichever behaviour suits their business model. When not stated in the README, any asset loss to users or protocol is a valid issue. User losses are expected to be a severity 3, but in this case, and given that those losses are inferior to the gas, the issue is downgraded to severity 2.
In the future, please state in the code any asset losses that are accepted by the protocol.
WatchPug
Given that importOperators()
will change operators
, and addressCache
will not be updated until rebuildCaches()
is called separately.
To ensure addressCache
is up-to-date, importOperators()
should be run atomically with rebuildCaches()
.
Consider changing importOperators()
to:
function importOperators(bytes32[] calldata names, address[] calldata operators, MixinOperatorResolver[] calldata destinations) external override onlyOwner { require(names.length == operators.length, "OperatorResolver::importOperators: Input lengths must match"); for (uint256 i = 0; i < names.length; i++) { bytes32 name = names[i]; address operator = operators[i]; operators[name] = operator; emit OperatorImported(name, operator); } _rebuildCaches(destinations); }
#0 - maximebrugel
2021-11-23T16:30:59Z
Maybe we want to import the Operators (in multiple transactions) without rebuilding straight away. So we can give MixinOperatorResolver[] calldata destinations
as an empty array.
#1 - alcueca
2021-12-03T10:22:51Z
Duplicate of #217
🌟 Selected for report: PierrickGT
Also found by: WatchPug, gzeon, harleythedog, pants
WatchPug
safeApprove
is deprecated.
#0 - maximebrugel
2021-11-18T08:49:51Z
Duplicated : #50
WatchPug
Calling ERC20.transfer()
without handling the returned value is unsafe.
function unlockTokens(IERC20 _token) external override onlyOwner { _token.transfer(owner(), _token.balanceOf(address(this))); }
Consider using OpenZeppelin's SafeERC20
library with safe versions of transfer functions.
#0 - adrien-supizet
2021-11-17T15:11:14Z
Duplicated : #78
WatchPug
/// @dev Calculate the fees for a specific user and amount (1%) /// @param _user The user address /// @param _amount The amount /// @return The fees amount function _calculateFees(address _user, uint256 _amount) private view returns (uint256) { return _amount / 100; }
The function _calculateFees()
is a rather simple function, replacing it with inline expression _amount / 100
can save some gas.
#0 - maximebrugel
2021-11-17T15:07:07Z
Also, making this function inline will remove the unused parameter _user
.
Issues pointing this unused parameter (or change to pure
) will be linked here.
#1 - alcueca
2021-12-03T10:14:50Z
Also comments are wrong (#194)
🌟 Selected for report: WatchPug
Also found by: fatima_naz
WatchPug
Contracts use assert() instead of require() in multiple places.
Per to Solidity’s documentation:
"Assert should only be used to test for internal errors, and to check invariants. Properly functioning code should never create a Panic, not even on invalid external input. If this happens, then there is a bug in your contract which you should fix. Language analysis tools can evaluate your contract to identify the conditions and function calls which will cause a Panic.”
assert(amountSpent <= _inputTokenAmount - feesAmount); // overspent
assert(amountSpent <= _inputTokenAmounts[i]);
assert(amountBought > 0); assert(amountSold > 0);
Change to require()
.
#0 - alcueca
2021-12-03T10:18:36Z
I'm upgrading this to Low Risk because there is an actual loss to the users, and the potential for a massive headache for the protocol operators.
WatchPug
Reading array length at each iteration of the loop takes 6 gas (3 for mload and 3 to place memory_offset) in the stack.
Caching the array length in the stack saves around 3 gas per iteration.
Instances include:
#0 - maximebrugel
2021-11-17T15:20:32Z
Duplicated : #7
10.4335 USDC - $10.43
WatchPug
Every reason string takes at least 32 bytes.
Use short reason strings that fits in 32 bytes or it will become more expensive.
Instances include:
require(i > 0, "NestedFactory::removeOperator: Cant remove non-existent operator");
require(address(reserve) == address(0), "NestedFactory::setReserve: Reserve is immutable");
require(address(_feeSplitter) != address(0), "NestedFactory::setFeeSplitter: Invalid feeSplitter address");
require(_orders.length > 0, "NestedFactory::create: Missing orders");
#0 - adrien-supizet
2021-11-18T12:43:41Z
duplicate #14
🌟 Selected for report: GiveMeTestEther
Also found by: WatchPug
47.7068 USDC - $47.71
WatchPug
In FeeSplitter.sol#setRoyaltiesWeight()
, totalWeights
is being written 2 times. Combing them into one storage write can save gas.
function setRoyaltiesWeight(uint256 _weight) public onlyOwner { totalWeights -= royaltiesWeight; royaltiesWeight = _weight; totalWeights += _weight; }
Change to:
function setRoyaltiesWeight(uint256 _weight) public onlyOwner { totalWeights = totalWeights - royaltiesWeight + _weight; royaltiesWeight = _weight; }
#0 - maximebrugel
2021-11-17T16:36:32Z
Duplicated : #28
🌟 Selected for report: GiveMeTestEther
19.3212 USDC - $19.32
WatchPug
function sendFees(IERC20 _token, uint256 _amount) external nonReentrant { uint256 weights = totalWeights - royaltiesWeight; _sendFees(_token, _amount, weights); }
The local variable weights
is used only once. Making the expression inline can save gas.
Change to:
function sendFees(IERC20 _token, uint256 _amount) external nonReentrant { _sendFees(_token, _amount, totalWeights - royaltiesWeight); }
#0 - adrien-supizet
2021-11-22T09:41:00Z
duplicate #46
🌟 Selected for report: pmerkleplant
13.9113 USDC - $13.91
WatchPug
uint256 balance = _sellToken.balanceOf(address(this));
balance
is unused. Removing this line can save gas.
#0 - maximebrugel
2021-11-22T13:33:35Z
Duplicated : #65
🌟 Selected for report: WatchPug
106.015 USDC - $106.02
WatchPug
releaseToken()
has nonReentrant
modifier, making releaseTokens()
to set storage _status
multiple times in the for loop.
function releaseToken(IERC20 _token) public nonReentrant { uint256 amount = _releaseToken(_msgSender(), _token); _token.safeTransfer(_msgSender(), amount); emit PaymentReleased(_msgSender(), address(_token), amount); } /// @notice Call releaseToken() for multiple tokens /// @param _tokens ERC20 tokens to release function releaseTokens(IERC20[] memory _tokens) external { for (uint256 i = 0; i < _tokens.length; i++) { releaseToken(_tokens[i]); } }
Change to:
function releaseToken(IERC20 _token) public nonReentrant { _releaseTokenAndTransfer(_token); } /// @notice Call releaseToken() for multiple tokens /// @param _tokens ERC20 tokens to release function releaseTokens(IERC20[] memory _tokens) external { for (uint256 i = 0; i < _tokens.length; i++) { _releaseTokenAndTransfer(_tokens[i]); } } function _releaseTokenAndTransfer(IERC20 _token) private { uint256 amount = _releaseToken(_msgSender(), _token); _token.safeTransfer(_msgSender(), amount); emit PaymentReleased(_msgSender(), address(_token), amount); }
#0 - adrien-supizet
2021-11-19T16:37:36Z
This is true, maybe the solution be to:
releaseToken
as internalreleaseTokens
releaseTokens
from the outside world (with only 1 token in the array if needed)🌟 Selected for report: WatchPug
WatchPug
The for loop in rebuildCache()
will cost more gas if removeOperator()
wont decrease operators.length
.
function rebuildCache() public { bytes32[] memory requiredAddresses = resolverAddressesRequired(); // The resolver must call this function whenever it updates its state for (uint256 i = 0; i < requiredAddresses.length; i++) { bytes32 name = requiredAddresses[i]; // Note: can only be invoked once the resolver has all the targets needed added address destination = resolver.getAddress(name); if (destination != address(0)) { addressCache[name] = destination; } else { delete addressCache[name]; } emit CacheUpdated(name, destination); } }
/// @inheritdoc INestedFactory function removeOperator(bytes32 operator) external override onlyOwner { uint256 i = 0; while (operators[i] != operator) { i++; } require(i > 0, "NestedFactory::removeOperator: Cant remove non-existent operator"); delete operators[i]; }
Change to:
/// @inheritdoc INestedFactory function removeOperator(bytes32 operator) external override onlyOwner { uint256 length = operators.length; for (uint256 i = 0; i < length; i++) { if (operators[i] == operator) { operators[i] = operators[length - 1]; operators.pop(); return; } } revert("NestedFactory::removeOperator: Cant remove non-existent operator"); }
#0 - maximebrugel
2021-11-17T15:28:25Z
Duplicated : #58
#1 - alcueca
2021-12-03T11:07:12Z
Part of #220
#2 - alcueca
2021-12-03T11:12:20Z
Actually, even if caused by the same code, this is a different issue.
47.7068 USDC - $47.71
WatchPug
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.
For example:
function _submitInOrders( uint256 _nftId, IERC20 _inputToken, uint256 _inputTokenAmount, Order[] calldata _orders, bool _reserved, bool _fromReserve ) private returns (uint256 feesAmount, IERC20 tokenSold) { _inputToken = _transferInputTokens(_nftId, _inputToken, _inputTokenAmount, _fromReserve); uint256 amountSpent; for (uint256 i = 0; i < _orders.length; i++) { amountSpent += _submitOrder(address(_inputToken), _orders[i].token, _nftId, _orders[i], _reserved); } feesAmount = _calculateFees(_msgSender(), amountSpent); assert(amountSpent <= _inputTokenAmount - feesAmount); // overspent // If input is from the reserve, update the records if (_fromReserve) { _decreaseHoldingAmount(_nftId, address(_inputToken), _inputTokenAmount); } _handleUnderSpending(_inputTokenAmount - feesAmount, amountSpent, _inputToken); tokenSold = _inputToken; }
_inputTokenAmount - feesAmount
at L306 will never underflow.
function updateShareholder(uint256 _accountIndex, uint256 _weight) external onlyOwner { require(_accountIndex + 1 <= shareholders.length, "FeeSplitter: INVALID_ACCOUNT_INDEX"); uint256 _totalWeights = totalWeights; _totalWeights -= shareholders[_accountIndex].weight; shareholders[_accountIndex].weight = _weight; _totalWeights += _weight; require(_totalWeights > 0, "FeeSplitter: TOTAL_WEIGHTS_ZERO"); totalWeights = _totalWeights; }
_accountIndex + 1
will never overflow.
function trigger() internal { uint256 balance = NST.balanceOf(address(this)); uint256 toBurn = (balance * burnPercentage) / 1000; uint256 toSendToReserve = balance - toBurn; _burnNST(toBurn); NST.safeTransfer(nstReserve, toSendToReserve); }
balance - toBurn
will never underflow.
#0 - maximebrugel
2021-11-22T16:12:29Z
We will use unchecked
blocks when the code remains readable (since it can't be used as an expression).
_inputTokenAmount - feesAmount
at L306 will never underflow.
Acknowledge : Won't be easy to read in this function, its better to keep _inputTokenAmount - feesAmount
in the assert
._accountIndex + 1
Duplicated : This will change in #207balance - toBurn
Acknowledge : No need to add this kind of optimization for an owner function🌟 Selected for report: WatchPug
106.015 USDC - $106.02
WatchPug
For the storage variables that will be accessed multiple times, cache and read from the stack can save ~100 gas from each extra read (SLOAD
after Berlin).
For example:
function sendFeesWithRoyalties( address _royaltiesTarget, IERC20 _token, uint256 _amount ) external nonReentrant { require(_royaltiesTarget != address(0), "FeeSplitter: INVALID_ROYALTIES_TARGET_ADDRESS"); _sendFees(_token, _amount, totalWeights); _addShares(_royaltiesTarget, _computeShareCount(_amount, royaltiesWeight, totalWeights), address(_token)); }
Change to:
function sendFeesWithRoyalties( address _royaltiesTarget, IERC20 _token, uint256 _amount ) external nonReentrant { require(_royaltiesTarget != address(0), "FeeSplitter: INVALID_ROYALTIES_TARGET_ADDRESS"); uint256 _totalWeights = totalWeights; _sendFees(_token, _amount, _totalWeights); _addShares(_royaltiesTarget, _computeShareCount(_amount, royaltiesWeight, _totalWeights), address(_token)); }
#0 - adrien-supizet
2021-11-22T18:02:28Z
✅ Before: | Contract · Method · Min · Max · Avg · # calls · usd (avg) │ | FeeSplitter · sendFeesWithRoyalties · 74018 · 159518 · 121043 · 4 · - │
After: | FeeSplitter · sendFeesWithRoyalties · 73923 · 159423 · 120948 · 4 · - │
🌟 Selected for report: ye0lde
Also found by: PierrickGT, WatchPug, hack3r-0m, pmerkleplant
13.9113 USDC - $13.91
WatchPug
Unused local variables in contracts increase contract size and gas usage at deployment.
Instances include:
(uint256[] memory amounts, address[] memory tokens) = OperatorHelpers.decodeDataAndRequire( data, _inputToken, _outputToken );
tokens
is unused.
(uint256[] memory amounts, address[] memory tokens) = OperatorHelpers.decodeDataAndRequire( data, _inputToken, _outputToken );
tokens
is unused.
#0 - adrien-supizet
2021-11-18T12:38:46Z
duplicate #195
#1 - maximebrugel
2021-11-30T16:19:21Z
also duplicate #67 & #66
#2 - alcueca
2021-12-03T09:58:07Z
#195 as the main