Platform: Code4rena
Start Date: 10/03/2022
Pot Size: $75,000 USDT
Total HM: 25
Participants: 54
Period: 7 days
Judge: pauliax
Total Solo HM: 10
Id: 97
League: ETH
Rank: 33/54
Findings: 2
Award: $253.98
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: hickuphh3
Also found by: 0v3rf10w, 0x1f8b, 0xDjango, 0xNazgul, 0xngndev, 0xwags, Cantor_Dust, CertoraInc, Dravee, IllIllI, PPrieditis, Ruhum, TerrierLover, WatchPug, XDms, benk10, berndartmueller, bitbopper, catchup, cmichel, cryptphi, csanuragjain, danb, defsec, gzeon, hagrid, hubble, jayjonah8, kenta, kyliek, minhquanym, rfa, robee, saian, samruna, throttle, ye0lde, z3s
136.5431 USDT - $136.54
Impact
There’s two issues here but I bundled them up into one because they’re related:
LiquidityPool#sendFundsToUser
, initialGas
is calculated using gasLeft()
and then after some calculations baseGas
is added, which is correct as this represents the intrinsic cost of calling a function. However, you are not accounting for the 1/64
cost of calling the function (EIP-150), which only allows to pass 63/64
of the available gas to the different call frames. This means that initialGas
, or gasLeft()
will be: AvailableGas * 63/64 - 21000
LiquidityPool#sendFundsToUser
, everything that happens after calculating amountToTransfer
is not accounted into the gas spent and I don’t think there’s a way to actually account for it other than add a sort of extraGas
state variable, which is wonky. This is not great because you are executing external calls afterwards, but as I said I don’t think there’s a clean way to fix it.
However, there’s an interesting thing that I believe should be brought up if you want to measure gas usage as precisely as the evm allows us. In LiquidityPool#getAmountToTransfer
, totalGasUsed
doesn’t take into account the update of the following mappings:
gasFeeAccumulatedByToken[tokenAddress]
gasFeeAccumulated[tokenAddress][_msgSender]
SSTORES
(20000 gas) and these will go unaccounted, so what he’s owed will be off by 40000
+ all the other small logic that isn’t accounted.Just as a sidenote, although I don’t think it applies here but you may find it interesting: gas refunds are paid post-transaction, so there’s no precise way to account for them inside the logic of a contract. gasLeft()
will not catch them.
The first issue can be addressed by doing: initialGas = (gasLeft() * 64)/63
The second issue, which is harder to handle, I can only that you can:
unaccountedGas
state variable with a setter that works as a sort of bump/bonus to account for all the logic after LiquidityPool#getAmountToTransfer#totalGasUsed
calculation. But this doesn’t take into account the first caller having to pay for the SSTORES
. To tackle that, the only thing I can think of is to warm the slots before totalGasUsed
is calculated. You would do something along the lines of:gasFeeAccumulatedByToken[tokenAddress] += 1; gasFeeAccumulated[tokenAddress][_msgSender] += 1; totalGasUsed = initialGas - gasLeft(); //..code gasFeeAccumulatedByToken[tokenAddress] += gasFee - 1; gasFeeAccumulated[tokenAddress][_msgSender] += gasFee - 1;
which is hacky, but it would properly account the 40000
extra gas of the first caller at the cost of an extra 200
gas cost per function call (warm SSTORES cost 100
each).
Summary
In ExecutorManager.sol
, errors messages have the following style:
executor address can not be 0
While in the rest of the contracts, they have this style:
ERR__TO_IS_ZERO
Two instances:
Two instances:
🌟 Selected for report: Dravee
Also found by: 0v3rf10w, 0x1f8b, 0xDjango, 0xNazgul, 0xngndev, 0xwags, Cantor_Dust, CertoraInc, IllIllI, Jujic, Kenshin, Kiep, PPrieditis, TerrierLover, Tomio, WatchPug, antonttc, benk10, berndartmueller, bitbopper, csanuragjain, defsec, gzeon, hagrid, hickuphh3, kenta, minhquanym, oyc_109, pedroais, peritoflores, rfa, robee, saian, samruna, sirhashalot, throttle, wuwe1, z3s
117.4352 USDT - $117.44
Impact
The modifier onlyExecutor
in ExecutorManager.sol
is not used in the repository. It can be removed. Only LiquidityPool.sol
uses a similar modifier with the same name, but it has it’s own implementation.
Steps to Mitigate
Remove onlyExecutor
from ExecutorManager.sol
to save on deployment costs.
Impact
Can reduce costs if you implement your partial version of Ownable
with only the variables, modifiers and function you need.
Steps to Mitigate
Create a new contract which is a partial implementation of Ownable
Impact
This is a common gas optimization possibility that appears in a lot of contracts. For this reason, wardens have added it to a list of common gas optimizations. I’ll link to the explanation, which is as detailed as it gets:
In short, if you are using Solidity version >=0.8.4
, the most gas-efficient approach is to create custom errors and use if (condition) revert customError()
syntax. The second best approach is to shorten your error strings to have a length less than 32. Here’s where you can apply the shortening optimization in your error messages:
Although I’d recommend using custom errors for maximum savings.
Impact
For loops where you don’t think i
will ever reach values of 2**256
can be optimized with the use of unchecked
. You can also cache the length prior to executing the for loop, to avoid computing it every time.
Steps to Mitigate
Here’s an example of one of your for loops:
function addExecutors(address[] calldata executorArray) external override onlyOwner { for (uint256 i = 0; i < executorArray.length; ++i) { addExecutor(executorArray[i]); } }
It can be optimized by doing:
function addExecutors(address[] calldata executorArray) external override onlyOwner { uint256 executorArrayLength = executorArray.length; for (uint256 i; i < executorArrayLength;) { unchecked { i++;} addExecutor(executorArray[i]); } }
Tools Used
Used mini contract to test this using Foundry. Here are the results:
Optimized contract: 194 bytes of code, 25699 gas cost
Non-optimized contract: 223 byted of code, 27254 gas cost
Here are the contracts:
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.10; contract ForLoop { uint256 randomStateVariable; uint256[] randomArray = [1,2,3,4,5,6,7,8,9,10]; function a() public { for (uint256 i = 0; i < randomArray.length; ++i) { randomStateVariable += i; } } } contract ForLoopOptimized { uint256 randomStateVariable; uint256[] randomArray = [1,2,3,4,5,6,7,8,9,10]; function a() public { uint256 length = randomArray.length; for (uint256 i; i < length;) { unchecked {i++;} randomStateVariable += i; } } }
And here the tests:
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.10; import "ds-test/test.sol"; import '../ForLoop.sol'; contract ForLoopTest is DSTest { ForLoop forLoop; ForLoopOptimized forLoopOptimized; function setUp() public { forLoop = new ForLoop(); forLoopOptimized = new ForLoopOptimized(); } function testForLoop() public { forLoop.a(); } function testForLoopOptimized() public { forLoopOptimized.a(); } }
Impact
Savings can be made by packing multiple variables into a single slots. In LiquidityFarming
there are three structs:
NFTInfo
: you can pack the bool and the address, but I didn’t find any meaningful save here.PoolInfo
: lastRewardTime holds the block.timestamp
, you can make this a uint32
valid up to 4294967296**,** which is Sunday, February 7, 2106 6:28:16 AM in human time. Or uint64
, which is valid up to Sunday, July 21, 2554 11:33:20 PM. And make the other variable uint224
or uint192
if you consider the values stored will fit in these uints.RewardsPerSecondEntry
: Same reasoning as PoolInfo
Impact
Save on gas and deployment costs by removing unnecessary nonReentrant modifiers. I’m pretty convinced these two are not necessary:
In the first case, no balances are updated after the external call, and I don’t see the function used anywhere else, so I believe it should be safe.
In the second case, the same applies, I don’t see a way to re-enter and mess with the balances or withdraw unlimited funds.
Impact
Save the user gas on reverts by eliminating any unnecessary computation happening before error checks here:
Steps to Mitigate
Refactor to:
function deposit(uint256 _nftId, address payable _to) external whenNotPaused nonReentrant { address msgSender = _msgSender(); require( lpToken.isApprovedForAll(msgSender, address(this)) || lpToken.getApproved(_nftId) == address(this), "ERR__NOT_APPROVED" ); (address baseToken, , uint256 amount) = lpToken.tokenMetadata(_nftId); require(rewardTokens[baseToken] != address(0), "ERR__POOL_NOT_INITIALIZED"); require(rewardRateLog[baseToken].length != 0, "ERR__POOL_NOT_INITIALIZED"); NFTInfo storage nft = nftInfo[_nftId]; require(!nft.isStaked, "ERR__NFT_ALREADY_STAKED"); lpToken.safeTransferFrom(msgSender, address(this), _nftId); amount /= liquidityProviders.BASE_DIVISOR(); PoolInfo memory pool = updatePool(baseToken); nft.isStaked = true; nft.staker = _to; nft.rewardDebt = (amount * pool.accTokenPerShare) / ACC_TOKEN_PRECISION; nftIdsStaked[_to].push(_nftId); totalSharesStaked[baseToken] += amount; emit LogDeposit(msgSender, baseToken, _nftId); }
Impact
Since solidity 0.8.0, the over/underflowing checks have been built into the evm. This wasn’t free. Solidity knows this and provides us with a way of cheapening the arithmetic operations we know won’t revert by using unchecked
.
You can do this here:
Because in the if we previously check that currentLiquidity < providedLiquity
, we know that liquidityDifference = providedLiquidity - currentLiquidity
can ever over or underflow. So we can cheapen that using unchecked
Steps to Mitigate
Add unchecked
to the liquidityDifference
calculation:
unchecked { uint256 liquidityDifference = providedLiquidity - currentLiquidity; }