LoopFi
Findings & Analysis Report
2024-06-12
Table of contents
- Summary
- Scope
- Severity Criteria
-
Low Risk and Non-Critical Issues
- 01
onlyAuthorized
modifier introduces unnecessary centralization via inPrelaunchPoints::convertAllETH
- 02 Not allowed tokens cannot be recovered if they get allowed after having been sent to the contract
- 03 Critical privilages are transferred in one step instead of two
- 04 No event emissions for critical state changes in
PrelaunchPoints::setEmergencyMode
andPrelaunchPoints::allowToken
- 05 Missing input validation for
_percentage
inPrelaunchPoints::_claim
- 06
PrelaunchPoints::_validateData
acceptsaddress(0)
as a valid value forrecipient
- 01
-
- G-01 Prevent re-setting a state variable with the same value
- G-02 State Variable Access Within a Loop
- G-03
address(this)
should be cached - G-04 Custom Errors in Solidity for Gas Efficiency
- G-05 Avoid repeating computations
- G-06 Divisions can be unchecked to save gas
- G-07 Stack variable is only used once
- G-08 Storage Layout Optimization
- G-09
Internal
functions only called once can be inlined to save gas - G-10 Consider pre-calculating the address of
address(this)
to save gas - G-11 Counting down in for statements is more gas efficient
- G-12 Use solady library where possible to save gas
- G-13 Consider using solady’s
FixedPointMathLib
- G-14 Consider using OZ EnumerateSet in place of nested mappings
- G-15 Use bitmap to save gas
- G-16 Usage of
uints
/ints
smaller than 32 bytes (256 bits) incurs overhead - G-17 Refactor modifiers to call a local function
- G-18 Avoid Unnecessary Public Variables
- G-19 Use
do while
loops intead of for loops - G-20 Using XOR (
^
) and AND (&
) bitwise equivalents for gas optimizations - G-21 The result of a function call should be cached rather than re-calling the function
- G-22 Avoid updating storage when the value hasn’t changed
- G-23 Avoid zero transfers calls
- G-24 Use calldata instead of memory for function arguments that do not get mutated
- G-25 Nesting
if
statements that uses&&
saves gas - G-26 Constructor Can Be Marked As Payable
- G-27 Use
selfbalance()
instead ofaddress(this).balance
- G-28 Optimize names to save gas
- G-29 Use
uint256(1)
/uint256(2)
instead oftrue
/false
to save gas for changes - G-30 Consider activating
via-ir
for deploying - G-31 Optimize Deployment Size by Fine-tuning IPFS Hash
- G-32 Low level
call
can be optimized with assembly - G-33 Assembly: Use scratch space for building calldata
- G-34 Use assembly to check for
0
- G-35 Use assembly to write
address
storage values - G-36 Use assembly to emit an
event
- G-37 Use assembly to validate
msg.sender
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the LoopFi smart contract system written in Solidity. The audit took place between May 1—May 8 2024.
Wardens
49 Wardens contributed reports to LoopFi:
- 0xrex
- 0xJoyBoy03
- 0xnev
- Rhaydden
- Krace
- gumgumzum
- novamanbg
- Greed
- y4y
- DMoore
- 0x04bytes
- 0xBugSlayer
- sldtyenj12
- yovchev_yoan
- sandy
- Evo
- Kirkeelee
- Sajjad
- caglankaan
- d3e4
- samuraii77
- Pechenite (Bozho and radev_sw)
- TheFabled
- pamprikrumplikas
- web3er
- ZanyBonzy
- SBSecurity (Slavcheww and Blckhv)
- nfmelendez
- Topmark
- XDZIBECX
- Bigsam
- shaflow2
- 0xSecuri
- petarP1998
- bbl4de
- _karanel
- btk
- peanuts
- SpicyMeatball
- chainchief
- popeye
- karsar
- cheatc0d3
- krisp
- oualidpro
- slvDev
- ParthMandale
This audit was judged by Koolex.
Final report assembled by liveactionllama.
Summary
The C4 analysis yielded a total of 1 vulnerability with a risk rating in the category of HIGH severity and 0 with a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 16 reports detailing issues with a risk rating of LOW severity or non-critical. There was also 1 report recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 LoopFi repository, and is composed of 1 smart contract written in the Solidity programming language and includes 296 lines of Solidity code.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (1)
[H-01] Availability of deposit invariant can be bypassed
Submitted by 0xnev, also found by Krace, 0xrex (1, 2), gumgumzum, novamanbg, Greed, y4y, DMoore, 0x04bytes, 0xBugSlayer, sldtyenj12, yovchev_yoan, 0xJoyBoy03 (1, 2), sandy, Evo, Kirkeelee, Sajjad, d3e4, samuraii77, Pechenite, TheFabled, Rhaydden (1, 2), web3er, ZanyBonzy, SBSecurity, nfmelendez, Topmark, XDZIBECX, Bigsam, shaflow2, 0xSecuri, petarP1998, bbl4de, _karanel, and btk
Impact
One of the main invariants stated in the audit is the following:
Deposits are active up to the lpETH contract and lpETHVault contract are set
However, there are currently two ways this invariant can be broken, allowing users to gain lpETH without explicitly locking tokens before contracts are set.
- Sandwich a call to
convertAllETH
by front-running to directly donate ETH and then back-running to claim lpETH via multiple lock positions - Bundle a transaction of donation and claiming with a previously locked position of wrapped LRT
This bypasses the onlyBeforeDate(loopActivation)
modifier included in all lock functions. It also potentially allows no cap in lpETH minted and also discourages users from locking ETH in the PrelaunchPoints.sol
contract before contract addresses are set (i.e. loopActivation
is assigned).
Proof of Concept
Scenario 1
Bundle a transaction of donation and claiming with a previously locked position of wrapped LRT. User can execute the following in one transaction, assuming the user previously has a locked position with any of the allowed LRTs.
- Donate ETH directly to contract.
- Call
claim()/claimAndStake()
, claiming all tokens, this swaps users wrapped LRT to ETH via_fillQuote
. - Since
claimedAmount
is set asaddress(this).balance
here,claimedAmount
is computed as the addition of ETH receive from LRT swapped + user donated ETH. claimedAmount
worth of lpETH is subsequently claimed/staked vialpETH.deposit()/lpETHVault.stake()
.
Scenario 2
Sandwich a call to convertAllETH
by front-running to directly donate ETH and then back-running to claim lpETH via multiple lock positions.
- Assume user has 10 positions of ETH/WETH locked each with 1 ether with different addresses, with 100 ETH total locked.
- Front-run call to
convertAllETH
by donating 1 ETH. This setstotalLpETH
to be 101 ETH as seen here. - Ratio of
totalLpETH : totalSupply
computed here is now 1.01 (1.01). - Back-run
convertAllETH
by executing claims for all 10 position, each claim will mint 0.01 ETH extra (1.01 lpETH).
This scenario is less likely than scenario 1 as it requires user to perfectly back-run convertAllETH()
with claims, although it also breaks another invariant of a 1:1 swap.
Users that deposit ETH/WETH get the correct amount of lpETH on claim (1 to 1 conversion)
Recommended Mitigation Steps
- For scenario 1, set
claimedAmount
to amount of ETH bought from LRT swap (such asbuyAmount
for uniV3). - For scenario 2, no fix is likely required since it would require users to risk their funds to be transferred by other users due to inflated
totalLpETH : totalSupply
ratio. If not, you can consider settingtotalLpETH
tototalSupply
and allow admin to retrieve any additional ETH donated.
Koolex (judge) increased severity to High
Low Risk and Non-Critical Issues
For this audit, 16 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by pamprikrumplikas received the top score from the judge.
The following wardens also submitted reports: peanuts, 0xrex, SpicyMeatball, ZanyBonzy, Pechenite, caglankaan, chainchief, popeye, karsar, cheatc0d3, 0xnev, krisp, oualidpro, slvDev, and ParthMandale.
[01] onlyAuthorized
modifier introduces unnecessary centralization via in PrelaunchPoints::convertAllETH
PrelaunchPoints::convertAllETH
is protected by the onlyAuthorized
modifier, restricting access to this function to the protocol owner:
function convertAllETH() external onlyAuthorized onlyBeforeDate(startClaimDate) {...}
Impact
The restriction deepens centralization unnecessarily, exposing users to associated risks, and making the flow of events less predictable to the users.
Users should be able to start claiming LpETH right after the TIMELOCK
period passes following the succesfull execution of a call to PrelaunchPoints::setLoopAddresses
.
Claims are enabled upon a successful call to PrelaunchPoints::convertAllETH
, but as long as it is protected by the onlyAuthorized
modifier, the authorized account can chose to delay to call this function or can decide to never call it at all, introducing uncertainty and risk for the users.
Recommended Mitigation Steps
Remove the onlyAuthorized
modifier in PrelaunchPoints::convertAllETH
.
[02] Not allowed tokens cannot be recovered if they get allowed after having been sent to the contract
PrelaunchPoints::recoverERC20
is designed to enable the protocol owner to recover any not allowed tokens that users mistakeanly send to the protocol. However, PrelaunchPoints::allowToken
does not check whether the protocol, by user mistake, has previously received any funds in the token to be allowed.
Impact
ERC20 funds are lost in the following scenario:
- ERC20 token
tokenA
is not allowed by the protocol. UserA
mistakeanly transfers sometokenA
to the protocol.- Protocol owner allows
tokenA
in the protocol, and does not check whether the contract already has any balance of the token. - When trying to recover the now allowed
tokenA
forUserA
,PrelaunchPoints::recoverERC20
will revert withNotValidToken
.
Proof of code:
function test_cannotRecoverTokenIfWasAllowedMeanwhile() public {
address user = makeAddr("user");
uint256 balance = 2e18;
// token that is not allowed yet
LRToken lrtB = new LRToken();
lrtB.mint(address(this), INITIAL_SUPPLY);
lrtB.transfer(user, balance);
vm.startPrank(user);
lrtB.approve(address(prelaunchPoints), balance);
lrtB.transfer(address(prelaunchPoints), balance);
vm.stopPrank();
// when it is allowed, it is possible to recover
prelaunchPoints.recoverERC20(address(lrtB), balance / 2);
// when it gets allowed, it becomes impossible to recover
prelaunchPoints.allowToken(address(lrtB));
vm.expectRevert(PrelaunchPoints.NotValidToken.selector);
prelaunchPoints.recoverERC20(address(lrtB), balance / 2);
}
Recommended Mitigation Steps
Implement a check so that a token cannot be allowed if the contract has any balance in that token - i.e. before allowing token X
, the protocol owner will need to recover any funds of token X
from the protocol.
function allowToken(address _token) external onlyAuthorized {
+ if (_token.balanceOf(address(this)) != 0) {
+ recoverERC20(_token, _token.balanceOf(address(this)));
+ }
isTokenAllowed[_token] = true;
}
- function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyAuthorized{...}
+ function recoverERC20(address tokenAddress, uint256 tokenAmount) public onlyAuthorized{...}
[03] Critical privilages are transferred in one step instead of two
owner
has critical privilages in the protocol. PrelaunchPoints::setOwner
allows the owner
to transfer ownership (and associated critical privilages) in a one-step process.
Impact
Critical owner
priviliges could be transferred to an incorrect address e.g. if
owner
mistakenly inputs an incorrect address when callingPrelaunchPoints::setOwner
,- the protocol becomes the victim of a Clipboard Replacement Attack: protocol owner copies the address that ownership is supposed to be transferred to, but a malware replaces the address on the clipboard with a different, attacker-controlled address that the protocol owner will eventually end of pasting when preparing to call
PrelaunchPoints::setOwner
.
With the ownership privilages transferred to an incorrect account, the whole protocol will be compromised/unusable.
Recommended Mitigation Steps
Transfer critical priviliges in a 2-step process. Modify PrelaunchPoints
as follows:
contract PrelaunchPoints {
...
+ address public newOwner;
...
- event OwnerUpdated(address newOwner);
+ event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
+ event NewOwnerProposed(address indexed proposedOwner);
...
- function setOwner(address _owner) external onlyAuthorized {
- owner = _owner;
- emit OwnerUpdated(_owner);
- }
+ // Step 1: Propose a new owner
+ function proposeNewOwner(address _newOwner) external onlyAuthorized {
+ newOwner = _newOwner;
+ emit NewOwnerProposed(_newOwner);
+ }
+ // Step 2: New owner accepts the ownership
+ function acceptOwnership() external {
+ require(msg.sender == newOwner, "Not the proposed owner");
+ emit OwnershipTransferred(owner, newOwner);
+ owner = newOwner;
+ newOwner = address(0);
+ }
...
}
[04] No event emissions for critical state changes in PrelaunchPoints::setEmergencyMode
and PrelaunchPoints::allowToken
PrelaunchPoints::setEmergencyMode
and PrelaunchPoints::allowToken
modify critical state variables, but no events are defined and emitted to broadcast the changes.
Impact
- Reduced transparency
- Difficulty to track changes
- Inefficient or impossible integration with other contracts and services
Recommended Mitigation Steps
Define and emit events for critical changes performed in PrelaunchPoints::setEmergencyMode
and PrelaunchPoints::allowToken
:
// Define events to be emitted on state changes
+ event EmergencyModeSet(bool mode);
+ event TokenAllowed(address token);
contract PrelaunchPoints {
...
function setEmergencyMode(bool _mode) external onlyAuthorized {
+ emit EmergencyModeSet(_mode);
emergencyMode = _mode;
}
function allowToken(address _token) external onlyAuthorized {
+ emit TokenAllowed(_token);
isTokenAllowed[_token] = true;
}
...
}
[05] Missing input validation for _percentage
in PrelaunchPoints::_claim
PrelaunchPoints::_claim
does not perform input validation on _percentage
. Transaction flow will continue and only fails much later in the execution.
Impact
Users calling PrelaunchPoints::claim
or PrelaunchPoints::claimAndStake
and accidentally inputting 0
for _percentage
will waste more gas, as the transaction will fail much later in the execution.
Recommended Mitigation Steps
Perform input validation on _percentage
as follows:
function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
internal
returns (uint256 claimedAmount)
{
uint256 userStake = balances[msg.sender][_token];
if (userStake == 0) {
revert NothingToClaim();
}
+ require(_percentage > 0 && _percentage <= 100, "Invalid percentage value");
...
}
[06] PrelaunchPoints::_validateData
accepts address(0)
as a valid value for recipient
PrelaunchPoints::_validateData
accepts address(0)
as a valid value for the recipient
field of bytes calldata _data
.
This essentially means that later in the transaction flow, when Prelaunchdata::_fillQuote
is called, the output tokens of the swap will be sent to address(0)
.
Impact
Loss of funds; this would essentially be equivalent to burning tokens.
Recommended Mitigation Steps
Ensure the recipient is strictly the contract itself to prevent unintended token loss:
function _validateData(address _token, uint256 _amount, Exchange _exchange, bytes calldata _data) internal view {
address inputToken;
address outputToken;
uint256 inputTokenAmount;
address recipient;
bytes4 selector;
if (_exchange == Exchange.UniswapV3) {
(inputToken, outputToken, inputTokenAmount, recipient, selector) = _decodeUniswapV3Data(_data);
if (selector != UNI_SELECTOR) {
revert WrongSelector(selector);
}
// UniswapV3Feature.sellTokenForEthToUniswapV3(encodedPath, sellAmount, minBuyAmount, recipient) requires `encodedPath` to be a Uniswap-encoded path, where the last token is WETH, and sends the NATIVE token to `recipient`
if (outputToken != address(WETH)) {
revert WrongDataTokens(inputToken, outputToken);
}
} else if (_exchange == Exchange.TransformERC20) {
(inputToken, outputToken, inputTokenAmount, selector) = _decodeTransformERC20Data(_data);
if (selector != TRANSFORM_SELECTOR) {
revert WrongSelector(selector);
}
if (outputToken != ETH) {
revert WrongDataTokens(inputToken, outputToken);
}
} else {
revert WrongExchange();
}
if (inputToken != _token) {
revert WrongDataTokens(inputToken, outputToken);
}
if (inputTokenAmount != _amount) {
revert WrongDataAmount(inputTokenAmount);
}
- if (recipient != address(this) && recipient != address(0)) {
+ if (recipient != address(this)) {
revert WrongRecipient(recipient);
}
}
0xd4n1el (Loop) confirmed and commented:
Excellent report, all findings could be considered.
Gas Optimizations
For this audit, the report highlighted below by caglankaan details gas optimizations and received the top score from the judge.
[G-01] Prevent re-setting a state variable with the same value
Not only is wasteful in terms of gas, but this is especially problematic when an event is emitted and the old and new values set are the same, as listeners might not expect this kind of scenario.
Path: ./src/PrelaunchPoints.sol
337: owner = _owner; // @audit-issue
353: lpETH = ILpETH(_loopAddress); // @audit-issue
354: lpETHVault = ILpETHVault(_vaultAddress); // @audit-issue
373: emergencyMode = _mode; // @audit-issue
Recommendation
Implement checks in your Solidity contracts to avoid re-setting state variables to their existing values. Prior to updating a state variable, compare the new value with the current value and proceed with the assignment only if they differ. Additionally, ensure that events related to state variable updates are emitted only when actual changes occur. This approach not only saves gas but also prevents confusion and unnecessary triggers in event listeners. Regularly reviewing and optimizing your contract for such redundancies can significantly enhance efficiency and clarity in contract operations.
[G-02] State Variable Access Within a Loop
State variable reads and writes are more expensive than local variable reads and writes. Therefore, it is recommended to replace state variable reads and writes within loops with a local variable. Gas savings should be multiplied by the average loop length.
Path: ./src/PrelaunchPoints.sol
108: isTokenAllowed[_allowedTokens[i]] = true; // @audit-issue: `isTokenAllowed` used in loop.
Recommendation
Optimize gas usage in Solidity by replacing state variable reads and writes within loops with local variable operations. This reduces gas costs significantly, especially when multiplied by the average loop length.
[G-03] address(this)
should be cached
Caching saves gas when compared to repeating the calculation at each point it is used in the contract.
Path: ./src/PrelaunchPoints.sol
321: uint256 totalBalance = address(this).balance; // @audit-issue: `adress(this)` also used on line(s): [324, 322]
503: boughtETHAmount = address(this).balance - boughtETHAmount; // @audit-issue: `adress(this)` also used on line(s): [493]
Recommendation
To enhance gas efficiency, cache the contract’s address by storing address(this)
in a state variable at the point of contract deployment or initialization. Use this cached address throughout the contract instead of repeatedly calling address(this)
. This practice reduces the gas cost associated with multiple computations of the contract’s address, leading to more efficient contract execution, especially in scenarios with frequent usage of the contract’s address.
[G-04] Custom Errors in Solidity for Gas Efficiency
Starting from Solidity version 0.8.4, the language introduced a feature known as “custom errors”. These custom errors provide a way for developers to define more descriptive and semantically meaningful error conditions without relying on string messages. Prior to this version, developers often used the require
statement with string error messages to handle specific conditions or validations. However, every unique string used as a revert reason consumes gas, making transactions more expensive.
Custom errors, on the other hand, are identified by their name and the types of their parameters only, and they do not have the overhead of string storage. This means that, when using custom errors instead of require
statements with string messages, the gas consumption can be significantly reduced, leading to more gas-efficient contracts.
Path: ./src/PrelaunchPoints.sol
495: require(_sellToken.approve(exchangeProxy, _amount)); // @audit-issue
Recommendation
It is recommended to use custom errors instead of revert strings to reduce gas costs, especially during contract deployment. Custom errors can be defined using the error keyword and can include dynamic information.
[G-05] Avoid repeating computations
In Solidity development, repeating the same computations within a contract can lead to unnecessary gas consumption and reduce the contract’s efficiency. This is particularly relevant in functions that are called frequently or involve complex calculations. Repeating computations not only wastes computational resources but also increases the cost of executing transactions. By identifying and eliminating redundant calculations, developers can optimize contract performance, reduce gas costs, and improve overall execution speed.
Path: ./src/PrelaunchPoints.sol
279: if (block.timestamp >= startClaimDate) { // @audit-issue: Same binary operation statement in line(s) between: ['291:291']
Recommendation
Review your Solidity contracts to identify any computations that are performed multiple times with the same inputs. Cache the results of these computations in local variables and reuse them within the function or across function calls if the state remains unchanged.
[G-06] Divisions can be unchecked to save gas
The expression type(int).min/(-1)
is the only case where division causes an overflow. Therefore, uncheck can be used to save gas in scenarios where it is certain that such an overflow will not occur.
Path: ./src/PrelaunchPoints.sol
253: uint256 userClaim = userStake * _percentage / 100; // @audit-issue
Recommendation
Utilize ‘unchecked’ blocks in Solidity for divisions where overflow is impossible, such as when type(int).min/(-1)
is not a concern. This can save gas by bypassing overflow checks in these specific cases.
[G-07] Stack variable is only used once
If the variable is only accessed once, it’s cheaper to use the assigned value directly that one time, and save the 3 gas the extra stack assignment would spend.
Path: ./src/PrelaunchPoints.sol
296: (bool sent,) = msg.sender.call{value: lockedAmount}(""); // @audit-issue: sent used only on line: 298
497: (bool success,) = payable(exchangeProxy).call{value: 0}(_swapCallData); // @audit-issue: success used only on line: 498
Recommendation
Eliminate single-use stack variables in Solidity to optimize gas consumption. Directly use the assigned value in the place of the variable. This approach saves the 3 gas typically used for the extra stack assignment, streamlining the function’s execution and enhancing overall gas efficiency.
[G-08] Storage Layout Optimization
Storage Layout Optimization in Solidity involves arranging state variables to minimize gas costs. Since storage is expensive, combining variables into as few slots as possible and deleting unneeded variables can significantly reduce the gas needed for contract operations.
Path: ./src/PrelaunchPoints.sol
25: ILpETH public lpETH; // @audit-issue: ['Current storage layout is like this: ', "However it can be optimized by 1 storage by storing variables like in order: \n\t`['totalSupply', 'totalLpETH', 'isTokenAllowed', 'balances', 'lpETH', 'lpETHVault', 'owner', 'loopActivation', 'startClaimDate', 'emergencyMode']`"]
26: ILpETHVault public lpETHVault;
27: IWETH public immutable WETH;
28: address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
29: address public immutable exchangeProxy;
30:
31: address public owner;
32:
33: uint256 public totalSupply;
34: uint256 public totalLpETH;
35: mapping(address => bool) public isTokenAllowed;
36:
37: enum Exchange {
38: UniswapV3,
39: TransformERC20
40: }
41:
42: bytes4 public constant UNI_SELECTOR = 0x803ba26d;
43: bytes4 public constant TRANSFORM_SELECTOR = 0x415565b0;
44:
45: uint32 public loopActivation;
46: uint32 public startClaimDate;
47: uint32 public constant TIMELOCK = 7 days;
48: bool public emergencyMode;
49:
50: mapping(address => mapping(address => uint256)) public balances; // User -> Token -> Balance
Recommendation
To optimize storage and reduce gas costs, rearrange the storage variables in a way that makes the most of each 32-byte storage slot.
[G-09] Internal
functions only called once can be inlined to save gas
If an internal function is only used once, there is no need to modularize it, unless the function calling it would otherwise be too long and complex. Not inlining costs 20 to 40 gas because of two extra JUMP instructions and additional stack operations needed for function calls.
Path: ./src/PrelaunchPoints.sol
405: function _validateData(address _token, uint256 _amount, Exchange _exchange, bytes calldata _data) internal view { // @audit-issue
448: function _decodeUniswapV3Data(bytes calldata _data) // @audit-issue
470: function _decodeTransformERC20Data(bytes calldata _data) // @audit-issue
491: function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal { // @audit-issue
Recommendation
Inline ‘internal’ functions in Solidity that are called only once to save gas. This avoids the additional gas cost of 20 to 40 units associated with extra JUMP instructions and stack operations required for separate function calls, unless the calling function becomes too complex.
[G-10] Consider pre-calculating the address of address(this)
to save gas
Use foundry
’s script.sol
or solady
’s LibRlp.sol
to save the value in a constant, which will avoid having to spend gas to push the value on the stack every time it’s used.
Path: ./src/PrelaunchPoints.sol
186: IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount); // @audit-issue
230: uint256 claimedAmount = _claim(_token, address(this), _percentage, _exchange, _data); // @audit-issue
262: claimedAmount = address(this).balance; // @audit-issue
321: uint256 totalBalance = address(this).balance; // @audit-issue
322: lpETH.deposit{value: totalBalance}(address(this)); // @audit-issue
324: totalLpETH = lpETH.balanceOf(address(this)); // @audit-issue
439: if (recipient != address(this) && recipient != address(0)) { // @audit-issue
493: uint256 boughtETHAmount = address(this).balance; // @audit-issue
503: boughtETHAmount = address(this).balance - boughtETHAmount; // @audit-issue
186, 230, 262, 321, 322, 324, 439, 493, 503
Recommendation
To enhance gas efficiency, cache the contract’s address by storing address(this)
in a state variable at the point of contract deployment or initialization. Use this cached address throughout the contract instead of repeatedly calling address(this)
. This practice reduces the gas cost associated with multiple computations of the contract’s address, leading to more efficient contract execution, especially in scenarios with frequent usage of the contract’s address.
[G-11] Counting down in for statements is more gas efficient
Looping downwards in Solidity is more gas efficient due to how the EVM compares variables. In a ‘for’ loop that counts down, the end condition is usually to compare with zero, which is cheaper than comparing with another number. As such, restructure your loops to count downwards where possible.
Path: ./src/PrelaunchPoints.sol
110: i++; // @audit-issue
Recommendation
Where feasible, refactor for
loops in your Solidity contracts to count downwards. Adjust the loop initialization, condition, and iteration statements to decrement the loop variable and terminate the loop when it reaches zero. This approach can lead to gas savings, making your contract more efficient in terms of execution costs. Ensure that this refactoring aligns with the logic and requirements of your contract, and thoroughly test to confirm that the revised loop behavior matches the intended functionality.
[G-12] Use solady library where possible to save gas
The following OpenZeppelin imports have a Solady equivalent, as such they can be used to save GAS as Solady modules have been specifically designed to be as GAS efficient as possible
Path: ./src/PrelaunchPoints.sol
4:import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; // @audit-issue
Recommendation
Evaluate and, where appropriate, integrate Solady modules in your Solidity contracts as alternatives to similar OpenZeppelin imports. Focus on areas where gas efficiency can be significantly improved. Ensure that any replacement with Solady’s modules does not compromise the security or functionality of your contracts. Conduct thorough testing and code reviews when making such substitutions to confirm compatibility and maintain the integrity of your application. Stay informed about updates and community feedback on both libraries to make informed decisions about their use in your projects.
[G-13] Consider using solady’s FixedPointMathLib
Using Solady’s FixedPointMathLib
for multiplication or division operations in Solidity can lead to significant gas savings. This library is designed to optimize fixed-point arithmetic operations, which are common in financial calculations involving tokens or currencies. By implementing more efficient algorithms and assembly optimizations, FixedPointMathLib
minimizes the computational resources required for these operations. For contracts that frequently perform such calculations, integrating this library can reduce transaction costs, thereby enhancing overall performance and cost-effectiveness. However, developers must ensure compatibility with their existing codebase and thoroughly test for accuracy and expected behavior to avoid any unintended consequences.
Path: ./src/PrelaunchPoints.sol
253: uint256 userClaim = userStake * _percentage / 100; // @audit-issue
Recommendation
Consider integrating Solady’s FixedPointMathLib
into your Solidity contracts for optimized fixed-point arithmetic operations. This library can provide substantial gas savings and enhance the performance of your contract. Before integration, evaluate how FixedPointMathLib
aligns with your contract’s requirements. Ensure thorough testing for accuracy and compatibility with your existing contract logic. Carefully document any changes and keep track of how these optimizations affect your contract’s operations to maintain transparency and reliability in your application. Adopting FixedPointMathLib
should be a considered decision, balancing the benefits of gas efficiency with the need for maintaining code clarity and functionality.
[G-14] Consider using OZ EnumerateSet in place of nested mappings
Nested mappings and multi-dimensional arrays in Solidity operate through a process of double hashing, wherein the original storage slot and the first key are concatenated and hashed, and then this hash is again concatenated with the second key and hashed. This process can be quite gas expensive due to the double-hashing operation and subsequent storage operation (sstore).
A possible optimization involves manually concatenating the keys followed by a single hash operation and an sstore. However, this technique introduces the risk of storage collision, especially when there are other nested hash maps in the contract that use the same key types. Because Solidity is unaware of the number and structure of nested hash maps in a contract, it follows a conservative approach in computing the storage slot to avoid possible collisions.
OpenZeppelin’s EnumerableSet provides a potential solution to this problem. It creates a data structure that combines the benefits of set operations with the ability to enumerate stored elements, which is not natively available in Solidity. EnumerableSet handles the element uniqueness internally and can therefore provide a more gas-efficient and collision-resistant alternative to nested mappings or multi-dimensional arrays in certain scenarios.
Path: ./src/PrelaunchPoints.sol
50: mapping(address => mapping(address => uint256)) public balances; // User -> Token -> Balance // @audit-issue
Recommendation
Consider using OpenZeppelin’s EnumerableSet library as a more gas-efficient and collision-resistant alternative to nested mappings or multi-dimensional arrays, especially when managing unique elements. This library simplifies the handling of unique data sets and allows for the enumeration of elements, a feature not natively supported in Solidity. When refactoring your contract, replace nested mappings or arrays with EnumerableSet where appropriate, ensuring both gas efficiency and enhanced functionality. Be sure to understand the use cases and limitations of EnumerableSet to apply it effectively in your contract’s design and implementation.
[G-15] Use bitmap to save gas
Bitmaps in Solidity are essentially a way of representing a set of boolean values within an integer type variable such as uint256
. Each bit in the integer represents a true or false value (1 or 0), thus allowing efficient storage of multiple boolean values.
Bitmaps can save gas in the Ethereum network because they condense a lot of information into a small amount of storage. In Ethereum, storage is one of the most significant costs in terms of gas usage. By reducing the amount of storage space needed, you can potentially save on gas fees.
Here’s a quick comparison:
If you were to represent 256 different boolean values in the traditional way, you would have to declare 256 different bool
variables. Given that each bool
occupies a storage slot and each storage slot costs 20,000 gas to initialize, you would end up paying a considerable amount of gas.
On the other hand, if you were to use a bitmap, you could store these 256 boolean values within a single uint256
variable. In other words, you’d only pay for a single storage slot, resulting in significant gas savings.
However, it’s important to note that while bitmaps can provide gas efficiencies, they do add complexity to the code, making it harder to read and maintain. Also, using bitmaps is efficient only when dealing with a large number of boolean variables that are frequently changed or accessed together.
In contrast, the straightforward counterpart to bitmaps would be using arrays or mappings to store boolean values, with each bool
value occupying its own storage slot. This approach is simpler and more readable but could potentially be more expensive in terms of gas usage.
Path: ./src/PrelaunchPoints.sol
108: isTokenAllowed[_allowedTokens[i]] = true; // @audit-issue
113: isTokenAllowed[_wethAddress] = true; // @audit-issue
365: isTokenAllowed[_token] = true; // @audit-issue
Recommendation
Consider using bitmaps in your Solidity contracts when you need to store and manipulate a large set of boolean values. This approach is particularly advantageous in terms of gas efficiency for scenarios involving frequent changes or accesses to these values. However, balance this efficiency with code readability and maintainability. Ensure that the use of bitmaps is well-documented, and consider the complexity it introduces into the code. Employ bitmaps judiciously, especially when their use results in significant gas savings, and the logic they represent is a core aspect of the contract’s functionality.
[G-16] Usage of uints
/ints
smaller than 32 bytes (256 bits) incurs overhead
When using elements that are smaller than 32 bytes, your contract’s gas usage may be higher. This is because the EVM operates on 32 bytes at a time. Therefore, if the element is smaller than that, the EVM must use more operations in order to reduce the size of the element from 32 bytes to the desired size.
https://docs.soliditylang.org/en/v0.8.11/internals/layout_in_storage.html
Each operation involving a uint8
costs an extra 22-28 gas (depending on whether the other operand is also a variable of type uint8
) as compared to ones involving uint256
, due to the compiler having to clear the higher bits of the memory word before operating on the uint8
, as well as the associated stack operations of doing so. Use a larger size then downcast where needed.
Path: ./src/PrelaunchPoints.sol
45: uint32 public loopActivation; // @audit-issue
46: uint32 public startClaimDate; // @audit-issue
47: uint32 public constant TIMELOCK = 7 days; // @audit-issue
211: function claim(address _token, uint8 _percentage, Exchange _exchange, bytes calldata _data) // @audit-issue
226: function claimAndStake(address _token, uint8 _percentage, Exchange _exchange, bytes calldata _data) // @audit-issue
240: function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data) // @audit-issue
Recommendation
Minimize gas overhead by using uint256
or int256
instead of smaller integer types in Solidity contracts. The EVM operates more efficiently with 32-byte sizes. Downcast to smaller types only when necessary, as operations with smaller types like uint8
incur extra gas due to additional EVM operations for size adjustment.
[G-17] Refactor modifiers to call a local function
Modifiers code is copied in all instances where it’s used, increasing bytecode size. If deployment gas costs are a concern for this contract, refactoring modifiers into functions can reduce bytecode size significantly at the cost of one JUMP.
Path: ./src/PrelaunchPoints.sol
511: modifier onlyAuthorized() { // @audit-issue
518: modifier onlyAfterDate(uint256 limitDate) { // @audit-issue
525: modifier onlyBeforeDate(uint256 limitDate) { // @audit-issue
Recommendation
Evaluate your contract’s use of modifiers, particularly those applied across multiple functions, to identify candidates for refactoring into functions. Convert these modifiers into external or public functions that perform the same checks or actions. Then, replace the modifier usage in function declarations with calls to these functions at the start of your functions
[G-18] Avoid Unnecessary Public Variables
Public state variables in Solidity automatically generate getter functions, increasing contract size and potentially leading to higher deployment and interaction costs. To optimize gas usage and contract efficiency, minimize the use of public variables unless external access is necessary. Instead, use internal or private visibility combined with explicit getter functions when required. This practice not only reduces contract size but also provides better control over data access and manipulation, enhancing security and readability. Prioritize lean, efficient contracts to ensure cost-effectiveness and better performance on the blockchain.
Path: ./src/PrelaunchPoints.sol
25: ILpETH public lpETH; // @audit-issue
26: ILpETHVault public lpETHVault; // @audit-issue
27: IWETH public immutable WETH; // @audit-issue
28: address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; // @audit-issue
29: address public immutable exchangeProxy; // @audit-issue
31: address public owner; // @audit-issue
33: uint256 public totalSupply; // @audit-issue
34: uint256 public totalLpETH; // @audit-issue
35: mapping(address => bool) public isTokenAllowed; // @audit-issue
42: bytes4 public constant UNI_SELECTOR = 0x803ba26d; // @audit-issue
43: bytes4 public constant TRANSFORM_SELECTOR = 0x415565b0; // @audit-issue
45: uint32 public loopActivation; // @audit-issue
46: uint32 public startClaimDate; // @audit-issue
47: uint32 public constant TIMELOCK = 7 days; // @audit-issue
48: bool public emergencyMode; // @audit-issue
50: mapping(address => mapping(address => uint256)) public balances; // User -> Token -> Balance // @audit-issue
25, 26, 27, 28, 29, 31, 33, 34, 35, 42, 43, 45, 46, 47, 48, 50
Recommendation
Avoid creating explicit getter functions for ‘public’ state variables in Solidity. The compiler automatically generates getters for such variables, making additional functions redundant. This practice helps reduce contract size, lowers deployment costs, and simplifies maintenance and understanding of the contract.
[G-19] Use do while
loops intead of for loops
A do while
loop will cost less gas since the condition is not being checked for the first iteration.
uint256 i = 1;
do {
param2 += i;
i++;
}
while (i < 50);
is better than
for(uint256 i = 1; i< 50; i++){
param1 += i;
}
Path: ./src/PrelaunchPoints.sol
107: for (uint256 i = 0; i < length;) { // @audit-issue
Recommendation
Where appropriate, consider using a do while
loop instead of a for
loop in your Solidity contracts. This is especially beneficial when the first iteration of the loop does not require a condition check. Refactor your loop logic to fit the do while
structure for more gas-efficient execution. However, ensure that the loop’s logic and termination conditions are correctly implemented to avoid infinite loops or other logical errors. Always balance gas efficiency with code readability and the specific requirements of your contract’s logic.
[G-20] Using XOR (^
) and AND (&
) bitwise equivalents for gas optimizations
Given 4 variables a, b, c and d represented as such:
0 0 0 0 0 1 1 0 <- a
0 1 1 0 0 1 1 0 <- b
0 0 0 0 0 0 0 0 <- c
1 1 1 1 1 1 1 1 <- d
To have a == b means that every 0 and 1 match on both variables. Meaning that a XOR (operator ^) would evaluate to 0 ((a ^ b) == 0), as it excludes by definition any equalities. Now, if a != b, this means that there’s at least somewhere a 1 and a 0 not matching between a and b, making (a ^ b) != 0. Both formulas are logically equivalent and using the XOR bitwise operator costs actually the same amount of gas. However, it is much cheaper to use the bitwise OR operator (|) than comparing the truthy or falsy values. These are logically equivalent too, as the OR bitwise operator (|) would result in a 1 somewhere if any value is not 0 between the XOR (^) statements, meaning if any XOR (^) statement verifies that its arguments are different.
Path: ./src/PrelaunchPoints.sol
144: if (_token == ETH) { // @audit-issue
158: if (_token == ETH) { // @audit-issue
176: if (_amount == 0) { // @audit-issue
179: if (_token == ETH) { // @audit-issue
188: if (_token == address(WETH)) { // @audit-issue
245: if (userStake == 0) { // @audit-issue
248: if (_token == ETH) { // @audit-issue
287: if (lockedAmount == 0) { // @audit-issue
290: if (_token == ETH) { // @audit-issue
380: if (tokenAddress == address(lpETH) || isTokenAllowed[tokenAddress]) { // @audit-issue
412: if (_exchange == Exchange.UniswapV3) { // @audit-issue
421: } else if (_exchange == Exchange.TransformERC20) { // @audit-issue
144, 158, 176, 179, 188, 245, 248, 287, 290, 380, 412, 421
Recommendation
Review your Solidity contracts to identify any computations that are performed multiple times with the same inputs. Cache the results of these computations in local variables and reuse them within the function or across function calls if the state remains unchanged.
[G-21] The result of a function call should be cached rather than re-calling the function
The function calls in solidity are expensive. If the same result of the same function calls are to be used several times, the result should be cached to reduce the gas consumption of repeated calls.
Path: ./src/PrelaunchPoints.sol
415: revert WrongSelector(selector); // @audit-issue: Function call `WrongSelector` is called multiple times at lines [424].
419: revert WrongDataTokens(inputToken, outputToken); // @audit-issue: Function call `WrongDataTokens` is called multiple times at lines [434, 427].
Recommendation
Cache the result of function calls in Solidity instead of making repeated calls to the same function. This practice significantly reduces gas consumption by minimizing costly function call operations.
[G-22] Avoid updating storage when the value hasn’t changed
Manipulating storage in solidity is gas-intensive. It can be optimized by avoiding unnecessary storage updates when the new value equals the existing value. If the old value is equal to the new value, not re-storing the value will avoid a Gsreset (2900 gas), potentially at the expense of a Gcoldsload (2100 gas) or a Gwarmaccess (100 gas).
Path: ./src/PrelaunchPoints.sol
337: owner = _owner; // @audit-issue
353: lpETH = ILpETH(_loopAddress); // @audit-issue
354: lpETHVault = ILpETHVault(_vaultAddress); // @audit-issue
373: emergencyMode = _mode; // @audit-issue
Recommendation
Optimize gas usage by avoiding storage updates in Solidity when the new value is the same as the existing one. This prevents unnecessary gas expenditure from storage resets, balancing the cost against cold or warm storage access as needed.
[G-23] Avoid zero transfers calls
In Solidity, unnecessary operations can waste gas. For example, a transfer function without a zero amount check uses gas even if called with a zero amount, since the contract state remains unchanged. Implementing a zero amount check avoids these unnecessary function calls, saving gas and improving efficiency.
Path: ./src/PrelaunchPoints.sol
383: IERC20(tokenAddress).safeTransfer(owner, tokenAmount); // @audit-issue
Recommendation
Include a condition in your transfer functions to check for and prevent zero-value transfers. Implement a require(amount > 0, "Transfer amount must be greater than zero");
statement at the beginning of the function. This preemptive check ensures that the function only proceeds with non-zero transfer amounts, avoiding wasteful operations and saving gas. Apply this optimization across all functions involving token transfers or similar operations to improve your contract’s gas efficiency and operational effectiveness.
[G-24] Use calldata instead of memory for function arguments that do not get mutated
Mark data types as calldata
instead of memory
where possible. This makes it so that the data is not automatically loaded into memory. If the data passed into the function does not need to be changed (like updating values in an array), it can be passed in as calldata
. The one exception to this is if the argument must later be passed into another function that takes an argument that specifies memory
storage.
Path: ./src/PrelaunchPoints.sol
97: constructor(address _exchangeProxy, address _wethAddress, address[] memory _allowedTokens) { // @audit-issue
Recommendation
To optimize gas usage in your Solidity functions, mark data types as calldata
instead of memory
wherever applicable. This prevents unnecessary data loading into memory. Use calldata
for function arguments that do not require changes within the function, except when passing them into another function that explicitly requires memory
storage.
[G-25] Nesting if
statements that uses &&
saves gas
In Solidity, the way conditional checks are structured can impact the gas consumption of a transaction. When conditions are combined using &&
within an if
statement, Solidity short-circuits the evaluation, meaning that if the first condition is false
, the subsequent conditions won’t be evaluated. This behavior can lead to gas savings compared to using separate nested if
statements because not all conditions might need to be checked every time. By efficiently structuring these conditional checks, contracts can optimize the gas required for execution, leading to reduced costs for users.
Path: ./src/PrelaunchPoints.sol
439: if (recipient != address(this) && recipient != address(0)) { // @audit-issue
Recommendation
When multiple conditions need to be checked successively, try to combine them in a single if
statement using &&
instead of nesting separate if
statements. This will leverage short-circuit evaluation for potential gas savings.
[G-26] Constructor Can Be Marked As Payable
payable
functions cost less gas to execute, since the compiler does not have to add extra checks to ensure that a payment wasn’t provided.
A constructor
can safely be marked as payable
, since only the deployer would be able to pass funds, and the project itself would not pass any funds.
Path: ./src/PrelaunchPoints.sol
97: constructor(address _exchangeProxy, address _wethAddress, address[] memory _allowedTokens) { // @audit-issue
Recommendation
Mark constructors as ‘payable’ in Solidity contracts to reduce gas costs, as this eliminates the need for the compiler to add checks against incoming payments. This is safe because only the deployer can send funds during contract creation, and typically no funds are sent at this stage.
[G-27] Use selfbalance()
instead of address(this).balance
In Solidity, contracts often need to query their own Ether balance. While address(this).balance
has been a widely used approach to retrieve the current contract’s balance, it is not the most gas-efficient method, especially with the introduction of the SELFBALANCE
opcode in more recent EVM versions.
The SELFBALANCE
opcode provides a more gas-efficient way to obtain the balance of the current contract. In Solidity, this can be invoked using the selfbalance()
function. Compared to the BALANCE
opcode used by address(this).balance
, SELFBALANCE
offers significant gas savings:
BALANCE
: The static gas cost is 0. If the accessed address is warm, the dynamic gas cost is 100. If the address is cold, the dynamic cost is 2,600.SELFBALANCE
: Semantically equivalent to theBALANCE
opcode when called on the contract’s own address, but with a dramatically reduced minimum gas cost of 5.
Switching to selfbalance()
can lead to significant gas savings, especially in operations or functions that frequently check the contract’s balance.
Path: ./src/PrelaunchPoints.sol
262: claimedAmount = address(this).balance; // @audit-issue
321: uint256 totalBalance = address(this).balance; // @audit-issue
493: uint256 boughtETHAmount = address(this).balance; // @audit-issue
503: boughtETHAmount = address(this).balance - boughtETHAmount; // @audit-issue
Recommendation
To optimize gas usage when querying a contract’s own Ether balance in Solidity, it’s recommended to use the selfbalance()
function, which utilizes the more gas-efficient SELFBALANCE
opcode. This can result in significant gas savings compared to address(this).balance
.
[G-28] Optimize names to save gas
public
/external
function names and public
member variable names can be optimized to save gas. Below are the interfaces/abstract contracts that can be optimized so that the most frequently-called functions use the least amount of gas possible during method lookup. Method IDs that have two leading zero bytes can save 128 gas each during deployment, and renaming functions to have lower method IDs will save 22 gas per call, per sorted position shifted.
Path: ./src/PrelaunchPoints.sol
16:contract PrelaunchPoints { // @audit-issue
Recommendation
Optimize gas usage by renaming ‘public’/‘external’ functions and ‘public’ member variables in Solidity. Aim for shorter and more efficient names, especially for frequently called functions. This can save gas during deployment and reduce gas costs per call due to lower method ID sorting positions.
[G-29] Use uint256(1)
/uint256(2)
instead of true
/false
to save gas for changes
Avoids a Gsset (20000 gas) when changing from false
to true
, after having been true
in the past. Since most of the bools aren’t changed twice in one transaction, I’ve counted the amount of gas as half of the full amount, for each variable. Note that public state variables can be re-written to be private
and use uint256
, but have public getters returning bool
s.
Path: ./src/PrelaunchPoints.sol
35: mapping(address => bool) public isTokenAllowed; // @audit-issue
48: bool public emergencyMode; // @audit-issue
Recommendation
To minimize gas overhead in your Solidity contracts, consider using uint256(1)
and uint256(2)
to represent true
and false
, respectively, instead of bool
types for storage. This approach avoids additional SLOAD
and SSTORE
operations, resulting in more gas-efficient code.
[G-30] Consider activating via-ir
for deploying
The IR-based code generator was developed to make code generation more performant by enabling optimization passes that can be applied across functions.
It is possible to activate the IR-based code generator through the command line by using the flag --via-ir
or by including the option {"viaIR": true}
.
Keep in mind that compiling with this option may take longer. However, you can simply test it before deploying your code. If you find that it provides better performance, you can add the --via-ir
flag to your deploy command.
/// @audit Global finding.
Recommendation
Consider activating via-ir
.
[G-31] Optimize Deployment Size by Fine-tuning IPFS Hash
The Solidity compiler appends 53 bytes of metadata to the smart contract code, incurring an extra cost of 10,600 gas. This additional expense arises from 200 gas per bytecode, plus calldata cost, which amounts to 16 gas for non-zero bytes and 4 gas for zero bytes. This results in a maximum of 848 extra gas in calldata cost.
Reducing this cost is crucial for the following reasons:
The metadata’s 53-byte addition leads to a deployment cost increase of 10,600 gas. It can also result in an additional calldata cost of up to 848 gas.
Ways to Minimize Gas Consumption:
Employ the --no-cbor-metadata
compiler option to exclude metadata. Be cautious as this might impact contract verification. Search for code comments that yield an IPFS hash with more zeros, thereby reducing calldata costs.
/// @audit Global finding.
Recommendation
To optimize deployment size and reduce associated costs, consider the following strategies:
- Exclude Metadata with Compiler Option: Use the
solc
compiler’s--metadata-hash none
or--no-cbor-metadata
option to prevent the inclusion of metadata in the compiled bytecode. This action reduces the bytecode size, thus lowering deployment gas costs. However, exercise caution with this approach, as it might affect the ability to verify the contract on platforms like Etherscan. - Optimize IPFS Hash for More Zeros: If excluding metadata is not desirable, another approach involves optimizing code comments or elements that influence the metadata hash generation to achieve an IPFS hash with a higher proportion of zeros. Since calldata costs are lower for zero bytes, a metadata hash with more zeros can reduce the calldata costs associated with contract interactions.
Example for excluding metadata:
solc --metadata-hash none YourContract.sol
[G-32] Low level call
can be optimized with assembly
returnData
is copied to memory even if the variable is not utilized: the proper way to handle this is through a low level assembly call.
// before
(bool success,) = payable(receiver).call{gas: gas, value: value}("");
//after
bool success;
assembly {
success := call(gas, receiver, value, 0, 0, 0, 0)
}
Path: ./src/PrelaunchPoints.sol
296: (bool sent,) = msg.sender.call{value: lockedAmount}(""); // @audit-issue
497: (bool success,) = payable(exchangeProxy).call{value: 0}(_swapCallData); // @audit-issue
Recommendation
Optimize low-level ‘call’ operations in Solidity using assembly, especially when ‘returnData’ is not needed. This avoids unnecessary copying to memory and can lead to gas savings. For example, replace (bool success,) = payable(receiver).call{gas: gas, value: value}("");
with an assembly block: assembly { success := call(gas, receiver, value, 0, 0, 0, 0) }
. This ensures a more efficient execution.
[G-33] Assembly: Use scratch space for building calldata
If an external call’s calldata can fit into two or fewer words, use the scratch space to build the calldata, rather than allowing Solidity to do a memory expansion.
Path: ./src/PrelaunchPoints.sol
189: WETH.withdraw(_amount); // @audit-issue
231: lpETH.approve(address(lpETHVault), claimedAmount); // @audit-issue
232: lpETHVault.stake(claimedAmount, msg.sender); // @audit-issue
249: claimedAmount = userStake.mulDiv(totalLpETH, totalSupply); // @audit-issue
251: lpETH.safeTransfer(_receiver, claimedAmount); // @audit-issue
302: IERC20(_token).safeTransfer(msg.sender, lockedAmount); // @audit-issue
324: totalLpETH = lpETH.balanceOf(address(this)); // @audit-issue
383: IERC20(tokenAddress).safeTransfer(owner, tokenAmount); // @audit-issue
495: require(_sellToken.approve(exchangeProxy, _amount)); // @audit-issue
189, 231, 232, 249, 251, 302, 324, 383, 495
Recommendation
Review your smart contracts to identify and remove block.number
and block.timestamp
from the parameters of emitted events. Instead of manually adding these fields, rely on the Ethereum blockchain’s inherent inclusion of this information within the block context. Simplify your event definitions to include only the essential data specific to the event’s purpose, excluding universally available block metadata.
[G-34] Use assembly to check for 0
Using assembly to check for zero can save gas by allowing more direct access to the evm and reducing some of the overhead associated with high-level operations in solidity.
Path: ./src/PrelaunchPoints.sol
176: if (_amount == 0) { // @audit-issue
245: if (userStake == 0) { // @audit-issue
287: if (lockedAmount == 0) { // @audit-issue
Recommendation
To optimize gas usage in your Solidity code, consider using inline assembly for checking 0
. This approach can significantly reduce gas costs, especially in high-frequency or gas-sensitive operations, leading to more efficient contract execution.
[G-35] Use assembly to write address
storage values
Using assembly { sstore(state.slot, addr)}
instead of state = addr
can save gas.
Path: ./src/PrelaunchPoints.sol
99: exchangeProxy = _exchangeProxy; // @audit-issue
100: WETH = IWETH(_wethAddress); // @audit-issue
337: owner = _owner; // @audit-issue
353: lpETH = ILpETH(_loopAddress); // @audit-issue
354: lpETHVault = ILpETHVault(_vaultAddress); // @audit-issue
Recommendation
To reduce gas costs in your Solidity code, consider using assembly with { sstore(state.slot, addr) }
for writing address
storage values instead of state = addr
. This approach can result in significant gas savings.
[G-36] Use assembly to emit an event
To efficiently emit events, it’s possible to utilize assembly by making use of scratch space and the free memory pointer. This approach has the advantage of potentially avoiding the costs associated with memory expansion.
However, it’s important to note that in order to safely optimize this process, it is preferable to cache and restore the free memory pointer.
A good example of such practice can be seen in Solady’s codebase.
Path: ./src/PrelaunchPoints.sol
197: emit Locked(_receiver, _amount, _token, _referral); // @audit-issue
234: emit StakedVault(msg.sender, claimedAmount); // @audit-issue
265: emit Claimed(msg.sender, _token, claimedAmount); // @audit-issue
305: emit Withdrawn(msg.sender, _token, lockedAmount); // @audit-issue
329: emit Converted(totalBalance, totalLpETH); // @audit-issue
339: emit OwnerUpdated(_owner); // @audit-issue
357: emit LoopAddressesUpdated(_loopAddress, _vaultAddress); // @audit-issue
385: emit Recovered(tokenAddress, tokenAmount); // @audit-issue
504: emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount); // @audit-issue
197, 234, 265, 305, 329, 339, 357, 385, 504
Recommendation
To optimize event emission in your Solidity code, consider using assembly with scratch space and the free memory pointer. This approach can help reduce gas costs by avoiding memory expansion expenses. However, it’s crucial to ensure safe optimization by caching and restoring the free memory pointer, as demonstrated in examples like Solady’s codebase.
[G-37] Use assembly to validate msg.sender
We can use assembly to efficiently validate msg.sender
with the least amount of opcodes necessary. For more details check the following report Here
Path: ./src/PrelaunchPoints.sol
512: if (msg.sender != owner) { // @audit-issue
Recommendation
To optimize the validation of msg.sender
in your Solidity code, consider using assembly to achieve this with the minimum number of opcodes required. You can refer to the detailed report Here for more insights and examples on efficient implementation.
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.