Appearance
L2-8: Gas Golf (Gas Optimization)
1. Problem
On Ethereum, every transaction execution consumes gas (EVM computational resources priced in gwei). An unoptimized contract might consume 200,000 gas, while after professional optimization it may require only 80,000 gas -- a difference of over 2.5x that directly translates to user transaction costs. For high-frequency scenarios (such as DEX swaps, NFT minting), gas optimization is not a nice-to-have; it is central to product competitiveness.
This challenge (refer to src/level2/GasGolf.sol) requires systematically mastering multiple gas optimization techniques and visually demonstrating the gas savings of each technique through comparative functions. The challenge covers six major technical dimensions: immutable/constant vs storage, variable packing, calldata vs memory, unchecked arithmetic, custom errors vs require strings, and short-circuit evaluation.
2. Why
Gas optimization is fundamentally about cost control at the EVM opcode level. Every SSTORE (storage write) costs 20,000 gas (cold write), while an immutable variable is written only once during construction and reads cost only about 5 gas afterwards. This 4,000x difference illustrates that understanding data storage mechanisms is more important than any code trick.
Gas Golf teaches not only how to save gas, but "what the cost of gas actually is." For example, each non-zero byte of calldata costs 16 gas and zero bytes cost 4 gas -- this explains why address(0) as a parameter is actually cheaper, and why compressing parameter type sizes (uint256 → uint128) saves gas. Similarly, unchecked blocks eliminate the overflow-checking opcode for each arithmetic operation (ADD vs unchecked_add), yielding particularly significant gas savings in loop accumulation scenarios.
More importantly, Gas Golf cultivates a "gas-aware" programming habit -- when writing every line of Solidity code, you subconsciously think "how much gas does this line cost? Is there a cheaper way?" This is the qualitative leap from "it works" to "professional contract development."
3. Solution
The GasGolf contract (refer to src/level2/GasGolf.sol) demonstrates comparative implementations of six gas optimization techniques:
Technique 1: immutable and constant vs storage
solidity
uint256 public constant MAX_SUPPLY = 10_000; // compile-time inline, ~5 gas/read
uint256 public immutable i_ownerFee; // set at construction, ~5 gas/read
uint256 public storageFee = 100; // SSTORE storage, 2100+ gas/read
function readImmutable() public view returns (uint256) {
return i_ownerFee + MAX_SUPPLY; // both are ~5 gas
}
function readStorage() public view returns (uint256) {
return storageFee + MAX_SUPPLY; // storageFee requires SLOAD (2100 gas)
}
Principle: constant is directly replaced with a literal at compile time (inlined into the bytecode), immutable is written into a special section of the contract code at deployment time (also only ~5 gas to read), while storage variables require the SLOAD opcode (2100 gas for cold reads).
Technique 2: Variable Packing
solidity
struct PackedData {
uint128 valueA;
uint128 valueB;
} // two uint128 share one 32-byte slot → one SSTORE
struct UnpackedData {
uint256 valueA;
uint256 valueB;
} // two independent slots → two SSTOREs
function writePacked(uint128 _a, uint128 _b) public {
packed = PackedData(_a, _b); // 1 × 20000 gas (cold)
}
function writeUnpacked(uint256 _a, uint256 _b) public {
unpacked = UnpackedData(_a, _b); // 2 × 20000 gas (cold)
}
Principle: EVM storage slots are aligned to 32 bytes (256 bits). Two uint128 values (16 bytes each) can sit side by side in the same 32-byte slot, and the EVM compiler will merge them into a single SSTORE. Two uint256 values each occupy a full slot, requiring two SSTOREs.
Technique 3: calldata vs memory
solidity
function sumCalldata(uint256[] calldata _data) public pure returns (uint256) {
uint256 total;
for (uint256 i; i < _data.length; ++i) {
total += _data[i]; // read directly from calldata
}
return total;
}
function sumMemory(uint256[] memory _data) public pure returns (uint256) {
uint256 total;
for (uint256 i; i < _data.length; ++i) {
total += _data[i]; // read from memory (requires prior copy)
}
return total;
}
Principle: memory parameters are fully copied from calldata to memory at function entry -- the larger the array, the higher the copy cost. calldata is read-only and points to the transaction's original input data, requiring no copy at all.
Technique 4: Unchecked Arithmetic
solidity
function sumUnchecked(uint256[] calldata _data) public pure returns (uint256) {
uint256 total;
unchecked {
for (uint256 i; i < _data.length; ++i) {
total += _data[i]; // skip overflow check
}
}
return total;
}
Principle: Solidity 0.8+ inserts overflow checks for every +, -, * operation by default. Inside loops, you can wrap operations in unchecked to skip these checks -- provided you are confident overflow is impossible (e.g., i can never reach 2^256).
Technique 5: Custom Errors vs require Strings
solidity
error InvalidValue(uint256 value);
function validateWithCustomError(uint256 _value) public pure returns (bool) {
if (_value == 0) revert InvalidValue(_value); // only 4-byte selector
return true;
}
function validateWithRequire(uint256 _value) public pure returns (bool) {
require(_value != 0, "Value cannot be zero"); // full string stored in bytecode
return true;
}
Principle: require's error string is stored entirely in the contract bytecode (each character is a byte; long messages can be hundreds of bytes), and is returned entirely to the caller on revert. Custom errors store only a 4-byte function selector + ABI-encoded parameters, significantly reducing bytecode size and return data volume.
Technique 6: Short-Circuit Evaluation
solidity
// Check cheap (constant read) first, then expensive (storage read)
function checkShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
if (_amount <= MAX_SUPPLY && storageFee > 0 && _addr != address(0)) {
return true;
}
return false;
}
// Bad practice: read storage first
function checkNoShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
if (storageFee > 0 && _amount <= MAX_SUPPLY && _addr != address(0)) {
return true;
}
return false;
}
Principle: Solidity's && is a short-circuit operator -- if the first condition is false, subsequent conditions are not evaluated. Place the cheapest conditions (constant reads, address(0) comparisons) first and the most expensive (storage reads) last.
4. Pitfalls Encountered
- constant is not the same as immutable: values assigned to
constantmust be determined at compile time (cannot depend on constructor parameters), otherwise compilation fails;immutablecan be assigned in the constructor - Read cost of packed structs: packing saves write gas, but reading an individual field requires additional shift operations (mask + shift), which in some scenarios may increase read cost
- unchecked causes silent overflow: if overflow occurs inside an
uncheckedblock, it wraps silently rather than reverting -- your contract may continue executing with incorrect state - calldata arrays cannot be modified: attempting a write operation on a calldata array (e.g.,
arr[0] = 5) will fail to compile, as it is a pointer to read-only data - Overusing
uncheckedin non-loop scenarios: for a singlea + boperation, the gas saved is minimal (about 3-5 gas), but the overflow risk introduced may be significant
5. Why the Pitfalls Happen
The fundamental difference between constant and immutable lies in assignment timing: constant is evaluated at compile time and must be a compile-time constant expression; immutable is evaluated at construction time and can depend on constructor parameters. Compile-time evaluation means constant variables are directly replaced with literals in the bytecode; construction-time evaluation means immutable variables are written into the contract code segment (akin to ROM) and cannot be modified afterwards. Therefore, constant cannot be address(this) or block.chainid -- values only known at runtime.
The read overhead of packed structs comes from the EVM's word size: 32 bytes. When you read a uint128 packed in the same slot, the EVM first reads the entire 32 bytes (SLOAD), then uses a mask to extract the lower 128 bits and a right shift to extract the upper 128 bits. While SLOAD cost far outweighs AND/SHR operations, if you frequently read packed variables and never write to them, packing brings no benefit.
The reason unchecked overflow doesn't revert is that the Solidity compiler directly uses the EVM's ADD opcode (which has natural overflow wrapping behavior) inside unchecked, rather than ADD + overflow check combination. This is an intentional performance design, but requires developers to guarantee safety themselves.
6. How to Resolve the Pitfalls
Use immutable for fixed values determined at construction time (such as owner fee rate), and constant for values fully determined at compile time (such as fixed supply cap). Their declaration syntax differs:
solidity
uint256 public constant MAX_SUPPLY = 10_000; // literal
uint256 public immutable i_ownerFee; // assigned in constructor
constructor(uint256 _ownerFee) { i_ownerFee = _ownerFee; }
When packing structs, weigh read/write frequency: if individual fields of the struct are read frequently, consider whether packing is worthwhile; for batch initialization with occasional reads, packing is always a win. Use memory caching to amortize read costs:
solidity
function readPacked() public view returns (uint128 a, uint128 b) {
PackedData memory p = packed; // one SLOAD, then operate in memory
return (p.valueA, p.valueB);
}
Inside unchecked blocks, annotate safety reasoning with comments and use Foundry fuzz tests to verify that boundary conditions do not overflow. For loop counters, it is almost always safe (can never reach 2^256), but for accumulators, ensure the maximum input won't overflow.
Always use calldata for reference-type parameters of external functions unless you genuinely need to modify the parameter. For scenarios requiring modification, create a memory copy inside the function first, then modify.
7. Technical Highlights
| Key Point | Description |
|---|---|
| SSTORE (cold 0→non-zero) | 20,000 gas -- highest cost for initial write |
| SSTORE (warm) | 5,000 gas -- non-zero to non-zero modification |
| SLOAD (cold) | 2,100 gas -- initial read |
| immutable/constant | ~5 gas -- inlined into bytecode, bypasses storage |
| calldata per non-zero byte | 16 gas |
| calldata per zero byte | 4 gas |
| Variable packing | two uint128 = one 32-byte slot → one SSTORE |
| unchecked arithmetic | skips overflow-checking opcodes for ADD/MUL/SUB |
| Custom error | 4-byte selector vs require string's full text |
| Short-circuit evaluation | && and ` |