Module 9: Gas Optimization

Module 9: Gas Optimization Techniques

9.1 Understanding Gas in Ethereum

Gas is a crucial concept in Ethereum that measures the computational effort required to execute operations. Understanding gas and optimizing for it is essential for creating efficient and cost-effective smart contracts.

What is Gas?

Gas is the unit that measures the amount of computational effort required to execute specific operations on the Ethereum network. Every operation in the Ethereum Virtual Machine (EVM) has a fixed gas cost.

Gas Components

When interacting with the Ethereum network, you need to consider:

  1. Gas Used: The total amount of gas consumed by a transaction
  2. Gas Price: The amount of Ether you're willing to pay per unit of gas (in gwei)
  3. Gas Limit: The maximum amount of gas you're willing to use for a transaction
  4. Transaction Fee: Gas Used × Gas Price

Gas Costs for Common Operations

Different operations have different gas costs:

Operation Gas Cost
Add/Subtract 3
Multiply/Divide 5
SSTORE (0 → non-0) 20,000
SSTORE (non-0 → non-0) 5,000
SSTORE (non-0 → 0) 5,000 (with 15,000 refund)
SLOAD 800
CALL 700
CREATE 32,000
External function call 700 + gas used by the function
Memory expansion 3 per byte

Why Gas Optimization Matters

  1. Cost Reduction: Lower gas usage means lower transaction costs for users
  2. User Experience: Faster and cheaper transactions improve user experience
  3. Block Gas Limit: Contracts that use too much gas might not fit in a block
  4. Contract Viability: High gas costs can make a contract economically unviable

9.2 Storage Optimization

Storage is the most expensive resource in Ethereum. Optimizing storage usage can significantly reduce gas costs.

Variable Packing

The EVM stores values in 32-byte (256-bit) slots. Multiple smaller variables can be packed into a single slot:

// Inefficient: Uses 3 storage slots (3 × 32 bytes)
uint256 a; // 32 bytes
uint256 b; // 32 bytes
uint256 c; // 32 bytes

// Efficient: Uses 1 storage slot (32 bytes total)
uint128 a; // 16 bytes
uint64 b;  // 8 bytes
uint64 c;  // 8 bytes

When variables are packed, they can be read and written in a single operation, saving gas.

Struct Packing

The same principle applies to structs:

// Inefficient: Uses 3 storage slots
struct UserInefficient {
    uint256 id;        // 32 bytes
    address userAddr;  // 20 bytes
    bool isActive;     // 1 byte
}

// Efficient: Uses 2 storage slots
struct UserEfficient {
    bool isActive;     // 1 byte
    address userAddr;  // 20 bytes
    uint256 id;        // 32 bytes
}

In the efficient version, isActive and userAddr are packed into a single slot, while id uses another slot.

Storage vs. Memory vs. Calldata

Using the appropriate data location can save gas:

// Expensive: Copies array to storage
function processArray(uint[] storage data) internal {
    // Process data
}

// Less expensive: Uses memory
function processArray(uint[] memory data) internal {
    // Process data
}

// Least expensive for external functions: Uses calldata
function processArray(uint[] calldata data) external {
    // Process data
}

For external functions that don't modify the input data, using calldata instead of memory can save gas by avoiding copying the data.

Minimize Storage Writes

Storage writes (SSTORE operations) are expensive. Minimize them when possible:

// Inefficient: Multiple storage writes
function updateValues(uint256 a, uint256 b, uint256 c) external {
    value1 = a;
    value2 = b;
    value3 = c;
}

// Efficient: Single storage write
function updateValues(uint256 a, uint256 b, uint256 c) external {
    PackedValues memory values = packedValues;
    values.value1 = a;
    values.value2 = b;
    values.value3 = c;
    packedValues = values;
}

Use Mappings Instead of Arrays

For large collections, mappings are often more gas-efficient than arrays:

// Can be expensive for large arrays
uint256[] public values;

// More efficient for random access
mapping(uint256 => uint256) public values;

Arrays store elements contiguously, which can lead to expensive operations when resizing. Mappings have constant-time access regardless of size.

9.3 Computation Optimization

Optimizing computations can significantly reduce gas usage.

Avoid Unnecessary Computations

Move complex calculations off-chain when possible:

// Expensive: On-chain calculation
function calculateComplexValue(uint256 a, uint256 b) public pure returns (uint256) {
    uint256 result = 0;
    for (uint256 i = 0; i < 100; i++) {
        result += (a * i) / (b + i);
    }
    return result;
}

// Efficient: Accept pre-calculated value
function useComplexValue(uint256 preCalculatedValue) public {
    // Use the value
}

Use Bitwise Operations

Bitwise operations are cheaper than arithmetic operations:

// Expensive
function multiply(uint256 a) public pure returns (uint256) {
    return a * 2;
}

// Cheaper
function multiplyBitwise(uint256 a) public pure returns (uint256) {
    return a << 1; // Left shift by 1 is equivalent to multiplying by 2
}

Other useful bitwise operations: - a >> 1 (right shift by 1): Divide by 2 - a & 1 (bitwise AND with 1): Check if odd - a | 1 (bitwise OR with 1): Ensure odd - a & (a - 1) (bitwise AND with a-1): Remove the lowest set bit

Short-Circuit Evaluation

Take advantage of short-circuit evaluation in logical expressions:

// Put the cheaper condition first
if (cheapCondition() || expensiveCondition()) {
    // Code
}

// Put the more likely condition first
if (likelyToBeTrue() || unlikelyButExpensive()) {
    // Code
}

Avoid Unnecessary Contract Calls

External contract calls are expensive. Minimize them when possible:

// Inefficient: Multiple calls
function processData(address dataContract) external {
    uint256 a = IDataContract(dataContract).getValue1();
    uint256 b = IDataContract(dataContract).getValue2();
    uint256 c = IDataContract(dataContract).getValue3();
    // Process a, b, c
}

// Efficient: Single call
function processData(address dataContract) external {
    (uint256 a, uint256 b, uint256 c) = IDataContract(dataContract).getAllValues();
    // Process a, b, c
}

Caching Values

Cache frequently used values instead of recomputing or reloading them:

// Inefficient: Multiple storage reads
function processUserData() external {
    require(users[msg.sender].isActive, "User not active");
    users[msg.sender].lastAccess = block.timestamp;
    uint256 balance = users[msg.sender].balance;
    // Process balance
}

// Efficient: Cache in memory
function processUserData() external {
    User storage user = users[msg.sender];
    require(user.isActive, "User not active");
    user.lastAccess = block.timestamp;
    uint256 balance = user.balance;
    // Process balance
}

9.4 Function Optimization

Optimizing function calls and execution can lead to significant gas savings.

Function Visibility

Choose the most restrictive visibility that works for your needs:

// Most expensive
function publicFunction() public {
    // Code
}

// Less expensive
function externalFunction() external {
    // Code
}

// Even less expensive
function internalFunction() internal {
    // Code
}

// Least expensive
function privateFunction() private {
    // Code
}

For functions that are only called externally, use external instead of public. The external functions can access arguments directly from calldata, which is cheaper than copying to memory.

Function Modifiers

Use function modifiers judiciously, as they can increase gas costs:

// Expensive: Multiple modifiers
modifier checkA() {
    require(conditionA, "Condition A failed");
    _;
}

modifier checkB() {
    require(conditionB, "Condition B failed");
    _;
}

function doSomething() public checkA checkB {
    // Function logic
}

// More efficient: Combined modifier or inline checks
function doSomething() public {
    require(conditionA, "Condition A failed");
    require(conditionB, "Condition B failed");
    // Function logic
}

View and Pure Functions

Mark functions as view or pure when appropriate:

// Correctly marked as view
function getValue() public view returns (uint256) {
    return myValue;
}

// Correctly marked as pure
function calculate(uint256 a, uint256 b) public pure returns (uint256) {
    return a + b;
}

These functions don't cost gas when called externally (from outside the blockchain), though they still cost gas when called from another contract.

Custom Errors (Solidity 0.8.4+)

Use custom errors instead of revert strings to save gas:

// Expensive: String error message
function transfer(address to, uint256 amount) public {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    // Transfer logic
}

// Cheaper: Custom error
error InsufficientBalance(address sender, uint256 requested, uint256 available);

function transfer(address to, uint256 amount) public {
    if (balances[msg.sender] < amount) {
        revert InsufficientBalance(msg.sender, amount, balances[msg.sender]);
    }
    // Transfer logic
}

Custom errors are more gas-efficient and can include parameters for better debugging.

Function Inlining

For small, frequently used functions, consider inlining the code:

// Function call overhead
function isEven(uint256 x) internal pure returns (bool) {
    return x % 2 == 0;
}

function processEvenNumbers(uint256[] memory numbers) public pure {
    for (uint256 i = 0; i < numbers.length; i++) {
        if (isEven(numbers[i])) {
            // Process even number
        }
    }
}

// Inlined code (more efficient)
function processEvenNumbers(uint256[] memory numbers) public pure {
    for (uint256 i = 0; i < numbers.length; i++) {
        if (numbers[i] % 2 == 0) {
            // Process even number
        }
    }
}

9.5 Loop Optimization

Loops can be gas-intensive. Optimizing them can lead to significant savings.

Fixed-Size Loops

When possible, use fixed-size loops instead of dynamic ones:

// Dynamic size (potentially expensive)
function sumArray(uint256[] memory values) public pure returns (uint256) {
    uint256 sum = 0;
    for (uint256 i = 0; i < values.length; i++) {
        sum += values[i];
    }
    return sum;
}

// Fixed size (more predictable gas usage)
function sumFixedArray(uint256[10] memory values) public pure returns (uint256) {
    uint256 sum = 0;
    for (uint256 i = 0; i < 10; i++) {
        sum += values[i];
    }
    return sum;
}

Avoid Storage Arrays in Loops

Reading from storage in loops is expensive:

// Expensive: Storage reads in loop
uint256[] public values;

function sumStorageArray() public view returns (uint256) {
    uint256 sum = 0;
    for (uint256 i = 0; i < values.length; i++) {
        sum += values[i]; // Storage read in each iteration
    }
    return sum;
}

// More efficient: Cache in memory first
function sumStorageArrayOptimized() public view returns (uint256) {
    uint256[] memory cachedValues = values; // Copy to memory once
    uint256 sum = 0;
    for (uint256 i = 0; i < cachedValues.length; i++) {
        sum += cachedValues[i]; // Memory read in each iteration
    }
    return sum;
}

Incremental Operations

Use ++i instead of i++ for small gas savings:

// Slightly more expensive
for (uint256 i = 0; i < 10; i++) {
    // Loop body
}

// Slightly cheaper
for (uint256 i = 0; i < 10; ++i) {
    // Loop body
}

The difference is that i++ creates a temporary variable to hold the original value, while ++i doesn't.

Loop Unrolling

For small, fixed-size loops, consider unrolling them:

// Loop
function sumFive(uint256[5] memory values) public pure returns (uint256) {
    uint256 sum = 0;
    for (uint256 i = 0; i < 5; i++) {
        sum += values[i];
    }
    return sum;
}

// Unrolled (more efficient)
function sumFiveUnrolled(uint256[5] memory values) public pure returns (uint256) {
    return values[0] + values[1] + values[2] + values[3] + values[4];
}

Early Loop Termination

Exit loops as soon as possible:

// Inefficient: Always processes all elements
function findValue(uint256[] memory values, uint256 target) public pure returns (bool) {
    bool found = false;
    for (uint256 i = 0; i < values.length; i++) {
        if (values[i] == target) {
            found = true;
        }
    }
    return found;
}

// Efficient: Exits loop early when found
function findValueOptimized(uint256[] memory values, uint256 target) public pure returns (bool) {
    for (uint256 i = 0; i < values.length; i++) {
        if (values[i] == target) {
            return true;
        }
    }
    return false;
}

9.6 Event and Error Optimization

Events and errors can be optimized for gas efficiency.

Indexed Event Parameters

Use indexed parameters judiciously:

// Three indexed parameters (maximum)
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);

// Two indexed parameters (saves gas if you don't need to filter by amount)
event Transfer(address indexed from, address indexed to, uint256 amount);

Each indexed parameter costs more gas to emit but enables efficient filtering.

Event Data Packing

Pack data into fewer parameters when possible:

// Inefficient: Multiple parameters
event UserUpdated(
    address user,
    string name,
    uint256 age,
    string email,
    string country
);

// Efficient: Packed into a single parameter
event UserUpdated(address user, bytes userData);
// Where userData is abi.encode(name, age, email, country)

Custom Errors vs. Require Strings

As mentioned earlier, custom errors are more gas-efficient than require strings:

// Expensive
require(balance >= amount, "ERC20: transfer amount exceeds balance");

// Cheaper
error InsufficientBalance(uint256 available, uint256 required);
if (balance < amount) {
    revert InsufficientBalance(balance, amount);
}

9.7 Practical Example: Gas-Optimized Token Contract

Let's create a gas-optimized ERC20 token contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

/**
 * @title GasOptimizedToken
 * @dev A gas-optimized ERC20 token implementation
 */
contract GasOptimizedToken {
    // Custom errors
    error InsufficientBalance(address sender, uint256 balance, uint256 needed);
    error InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
    error TransferToZeroAddress();
    error ApproveFromZeroAddress();
    error ApproveToZeroAddress();
    error TransferFromZeroAddress();

    // Events
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    // Token metadata
    string private _name;
    string private _symbol;
    uint8 private immutable _decimals;

    // Token balances and allowances
    mapping(address => uint256) private _balances;
    mapping(address => mapping(address => uint256)) private _allowances;
    uint256 private _totalSupply;

    // Constructor
    constructor(string memory name_, string memory symbol_, uint8 decimals_, uint256 initialSupply_) {
        _name = name_;
        _symbol = symbol_;
        _decimals = decimals_;

        // Mint initial supply to the deployer
        _mint(msg.sender, initialSupply_);
    }

    // Public view functions
    function name() public view returns (string memory) {
        return _name;
    }

    function symbol() public view returns (string memory) {
        return _symbol;
    }

    function decimals() public view returns (uint8) {
        return _decimals;
    }

    function totalSupply() public view returns (uint256) {
        return _totalSupply;
    }

    function balanceOf(address account) public view returns (uint256) {
        return _balances[account];
    }

    function allowance(address owner, address spender) public view returns (uint256) {
        return _allowances[owner][spender];
    }

    // Transfer functions
    function transfer(address to, uint256 amount) public returns (bool) {
        address owner = msg.sender;
        _transfer(owner, to, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        address spender = msg.sender;
        _spendAllowance(from, spender, amount);
        _transfer(from, to, amount);
        return true;
    }

    // Approval functions
    function approve(address spender, uint256 amount) public returns (bool) {
        address owner = msg.sender;
        _approve(owner, spender, amount);
        return true;
    }

    function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
        address owner = msg.sender;
        _approve(owner, spender, _allowances[owner][spender] + addedValue);
        return true;
    }

    function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) {
        address owner = msg.sender;
        uint256 currentAllowance = _allowances[owner][spender];
        if (currentAllowance < subtractedValue) {
            revert InsufficientAllowance(spender, currentAllowance, subtractedValue);
        }
        unchecked {
            _approve(owner, spender, currentAllowance - subtractedValue);
        }
        return true;
    }

    // Internal functions
    function _transfer(address from, address to, uint256 amount) internal {
        if (from == address(0)) {
            revert TransferFromZeroAddress();
        }
        if (to == address(0)) {
            revert TransferToZeroAddress();
        }

        uint256 fromBalance = _balances[from];
        if (fromBalance < amount) {
            revert InsufficientBalance(from, fromBalance, amount);
        }

        // Optimization: Use unchecked for gas savings (safe after the above check)
        unchecked {
            _balances[from] = fromBalance - amount;
        }
        _balances[to] += amount;

        emit Transfer(from, to, amount);
    }

    function _mint(address account, uint256 amount) internal {
        if (account == address(0)) {
            revert TransferToZeroAddress();
        }

        _totalSupply += amount;
        _balances[account] += amount;

        emit Transfer(address(0), account, amount);
    }

    function _approve(address owner, address spender, uint256 amount) internal {
        if (owner == address(0)) {
            revert ApproveFromZeroAddress();
        }
        if (spender == address(0)) {
            revert ApproveToZeroAddress();
        }

        _allowances[owner][spender] = amount;

        emit Approval(owner, spender, amount);
    }

    function _spendAllowance(address owner, address spender, uint256 amount) internal {
        uint256 currentAllowance = _allowances[owner][spender];
        if (currentAllowance < amount) {
            revert InsufficientAllowance(spender, currentAllowance, amount);
        }

        // Optimization: Use unchecked for gas savings (safe after the above check)
        unchecked {
            _allowances[owner][spender] = currentAllowance - amount;
        }
    }
}

This token contract incorporates numerous gas optimizations:

  1. Custom errors: Uses custom errors instead of revert strings
  2. Unchecked arithmetic: Uses unchecked blocks for arithmetic operations that can't overflow
  3. Immutable variables: Uses immutable for decimals to save gas
  4. Efficient storage: Uses appropriate variable types
  5. Function visibility: Uses internal for helper functions
  6. Minimal storage reads: Caches values like _balances[from] to avoid multiple reads
  7. Minimal events: Emits only the required events

9.8 Gas Measurement and Profiling

To optimize gas usage effectively, you need to measure and profile your contract's gas consumption.

Using Hardhat for Gas Reporting

Hardhat has plugins for gas reporting:

// Install the plugin
npm install hardhat-gas-reporter --save-dev

// In hardhat.config.js
require("hardhat-gas-reporter");

module.exports = {
  gasReporter: {
    enabled: true,
    currency: 'USD',
    gasPrice: 21,
    coinmarketcap: 'your-api-key-here'
  }
};

This will generate a gas report when you run your tests.

Using Remix for Gas Estimation

Remix IDE provides gas estimates for function calls:

  1. Compile your contract
  2. Deploy it to the JavaScript VM
  3. Look at the gas estimates for each function call

Manual Gas Profiling

You can manually profile gas usage in your tests:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("GasProfile", function() {
  it("Should profile gas usage", async function() {
    const Token = await ethers.getContractFactory("GasOptimizedToken");
    const token = await Token.deploy("Gas Token", "GAS", 18, ethers.utils.parseEther("1000000"));
    await token.deployed();

    // Get gas used for a transfer
    const tx = await token.transfer(ethers.constants.AddressZero, 100);
    const receipt = await tx.wait();
    console.log(`Gas used for transfer: ${receipt.gasUsed.toString()}`);
  });
});

Comparing Implementations

To find the most gas-efficient implementation, compare different approaches:

it("Should compare implementations", async function() {
  // Implementation A
  const TokenA = await ethers.getContractFactory("TokenImplementationA");
  const tokenA = await TokenA.deploy();
  await tokenA.deployed();

  const txA = await tokenA.someFunction();
  const receiptA = await txA.wait();

  // Implementation B
  const TokenB = await ethers.getContractFactory("TokenImplementationB");
  const tokenB = await TokenB.deploy();
  await tokenB.deployed();

  const txB = await tokenB.someFunction();
  const receiptB = await txB.wait();

  console.log(`Gas used A: ${receiptA.gasUsed.toString()}`);
  console.log(`Gas used B: ${receiptB.gasUsed.toString()}`);
  console.log(`Difference: ${receiptA.gasUsed.sub(receiptB.gasUsed).toString()}`);
});

9.9 Exercise: Optimize a Contract for Gas Efficiency

Task: Optimize the following contract for gas efficiency:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Unoptimized {
    struct UserData {
        bool isActive;
        uint256 balance;
        string name;
        address userAddress;
    }

    UserData[] public users;
    mapping(address => uint256) public userIndices;

    function addUser(string memory name) public {
        require(userIndices[msg.sender] == 0, "User already exists");

        UserData memory newUser;
        newUser.isActive = true;
        newUser.balance = 0;
        newUser.name = name;
        newUser.userAddress = msg.sender;

        users.push(newUser);
        userIndices[msg.sender] = users.length;
    }

    function updateBalance(uint256 amount) public {
        require(userIndices[msg.sender] > 0, "User does not exist");

        uint256 index = userIndices[msg.sender] - 1;
        UserData storage user = users[index];
        user.balance = user.balance + amount;
    }

    function getUserBalance(address userAddress) public view returns (uint256) {
        require(userIndices[userAddress] > 0, "User does not exist");

        uint256 index = userIndices[userAddress] - 1;
        return users[index].balance;
    }

    function deactivateUser() public {
        require(userIndices[msg.sender] > 0, "User does not exist");

        uint256 index = userIndices[msg.sender] - 1;
        UserData storage user = users[index];
        user.isActive = false;
    }

    function isUserActive(address userAddress) public view returns (bool) {
        require(userIndices[userAddress] > 0, "User does not exist");

        uint256 index = userIndices[userAddress] - 1;
        return users[index].isActive;
    }
}

Solution:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

contract Optimized {
    // Custom errors
    error UserAlreadyExists();
    error UserDoesNotExist();

    // Optimized struct: Pack related fields, order by size
    struct UserData {
        uint96 balance;     // 12 bytes
        address userAddress; // 20 bytes
        bool isActive;      // 1 byte
        // Total: 33 bytes, fits in 2 storage slots (with name in a separate slot)
    }

    // Store name separately to avoid reading it when not needed
    mapping(address => string) private userNames;
    mapping(address => UserData) private userData;

    // Events
    event UserAdded(address indexed user, string name);
    event BalanceUpdated(address indexed user, uint96 newBalance);
    event UserDeactivated(address indexed user);

    function addUser(string calldata name) external {
        if (userData[msg.sender].userAddress != address(0)) {
            revert UserAlreadyExists();
        }

        userData[msg.sender] = UserData({
            balance: 0,
            userAddress: msg.sender,
            isActive: true
        });

        userNames[msg.sender] = name;

        emit UserAdded(msg.sender, name);
    }

    function updateBalance(uint96 amount) external {
        UserData storage user = userData[msg.sender];

        if (user.userAddress == address(0)) {
            revert UserDoesNotExist();
        }

        // Use unchecked for gas savings (assuming no overflow risk)
        unchecked {
            user.balance += amount;
        }

        emit BalanceUpdated(msg.sender, user.balance);
    }

    function getUserBalance(address userAddress) external view returns (uint96) {
        UserData storage user = userData[userAddress];

        if (user.userAddress == address(0)) {
            revert UserDoesNotExist();
        }

        return user.balance;
    }

    function deactivateUser() external {
        UserData storage user = userData[msg.sender];

        if (user.userAddress == address(0)) {
            revert UserDoesNotExist();
        }

        user.isActive = false;

        emit UserDeactivated(msg.sender);
    }

    function isUserActive(address userAddress) external view returns (bool) {
        UserData storage user = userData[userAddress];

        if (user.userAddress == address(0)) {
            revert UserDoesNotExist();
        }

        return user.isActive;
    }

    function getUserName(address userAddress) external view returns (string memory) {
        UserData storage user = userData[userAddress];

        if (user.userAddress == address(0)) {
            revert UserDoesNotExist();
        }

        return userNames[userAddress];
    }
}

This optimized version includes several gas-saving improvements:

  1. Struct packing: Reorganized the struct fields to minimize storage slots
  2. Mapping instead of array: Replaced the array with a mapping for direct access
  3. Custom errors: Used custom errors instead of require statements
  4. Calldata for input: Used calldata for the name parameter in addUser
  5. Unchecked arithmetic: Used unchecked block for balance updates
  6. Separate storage for strings: Stored names separately to avoid reading them unnecessarily
  7. Specific uint size: Used uint96 for balance to enable better packing
  8. Events: Added events for important state changes
  9. Specific compiler version: Used a specific Solidity version

Conclusion

Gas optimization is a critical skill for Solidity developers. By understanding how gas works and applying optimization techniques, you can create more efficient and cost-effective smart contracts.

Key takeaways from this module:

  1. Storage operations are the most expensive, so optimize storage usage through variable packing and minimizing writes.
  2. Use appropriate data locations (storage, memory, calldata) based on your needs.
  3. Optimize computations by avoiding unnecessary calculations and using efficient operations.
  4. Choose the right function visibility and use custom errors for better gas efficiency.
  5. Optimize loops by caching values and exiting early when possible.
  6. Measure and profile gas usage to identify optimization opportunities.

In the next module, we'll explore design patterns in Solidity, which can help you write more maintainable and secure smart contracts.