Gas Optimization Testing in Solidity: Benchmarks and Regression Tests

Gas Optimization Testing in Solidity: Benchmarks and Regression Tests

Gas optimization in Solidity occupies a strange position: everyone knows it matters, but few teams treat gas costs as something to test and track. The result is contracts that ship with unnecessary gas costs, regressions that sneak in during refactoring, and "optimizations" that make no measurable difference.

Treating gas as a first-class testing concern — with benchmarks, regression tests, and CI enforcement — is what separates teams that optimize effectively from teams that optimize blindly. This guide covers the full stack: Hardhat gas reporter, Foundry gas snapshots, writing explicit gas regression tests, common optimization patterns worth benchmarking, and setting gas budgets that block regressions in CI.

Why Gas Testing Matters

A contract function that costs 50,000 gas instead of 30,000 gas is a 67% regression. At 20 gwei and $3000/ETH, that's the difference between $3 and $1.80 per call. For a protocol doing 10,000 calls per day, that's $4,380 extra per day in user costs — before you've noticed anything is wrong.

Gas regressions sneak in through:

  • Changing uint256 to string for better readability
  • Adding validation that touches storage
  • Changing data locations from calldata to memory
  • Inheritance restructuring that changes storage layout
  • OpenZeppelin version upgrades that change base contract storage

Without gas tracking, you won't catch these until users complain.

Hardhat Gas Reporter

The hardhat-gas-reporter plugin prints a gas usage table after every test run, showing average, minimum, and maximum gas per function call.

npm install --save-dev hardhat-gas-reporter

Configure it in hardhat.config.ts:

import "hardhat-gas-reporter";

const config: HardhatUserConfig = {
  solidity: "0.8.24",
  gasReporter: {
    enabled: process.env.REPORT_GAS !== undefined,
    currency: "USD",
    gasPrice: 20, // gwei — set to current typical price
    coinmarketcap: process.env.COINMARKETCAP_API_KEY, // optional: for USD conversion
    outputFile: "gas-report.txt",
    noColors: true, // cleaner for CI
    reportPureAndViewMethods: false, // only state-changing calls
    excludeContracts: ["MockERC20", "TestHelper"],
  },
};

Run with reporting enabled:

REPORT_GAS=true npx hardhat test

The output looks like:

·------------------------------|----------------------------|-------------|-----------------------------·
|  Solidity and Network        ·  Gas                       ·  Transaction cost                       |
·------------------------------|-------------|--------------|-----------------------------·
|  Methods                     ·  avg        ·  # calls     ·  USD (avg)                  |
···············|···············|·············|··············|·············|···············|
|  Vault       ·  deposit      ·      52413  ·          15  ·       $2.09 |
|  Vault       ·  withdraw     ·      41022  ·          10  ·       $1.64 |
|  Vault       ·  initialize   ·     182331  ·           3  ·       $7.28 |
·------------------------------|-------------|--------------|·············|···············|

This is useful for visibility, but it doesn't block regressions automatically. For that, you need explicit tests.

Writing Explicit Gas Tests

The most reliable approach is testing gas costs in your test suite:

import { expect } from "chai";
import { ethers } from "hardhat";
import { Vault } from "../typechain-types";

describe("Gas regression tests", () => {
  let vault: Vault;
  let token: any;
  let user: any;

  beforeEach(async () => {
    [, user] = await ethers.getSigners();
    // deploy vault and token...
  });

  it("deposit costs less than 55,000 gas", async () => {
    const amount = ethers.parseEther("100");
    await token.mint(user.address, amount);
    await token.connect(user).approve(vault.target, amount);

    const tx = await vault.connect(user).deposit(amount);
    const receipt = await tx.wait();

    expect(receipt!.gasUsed).to.be.lessThan(55_000n);
  });

  it("withdraw costs less than 45,000 gas", async () => {
    // Setup: make a deposit first
    const amount = ethers.parseEther("100");
    await token.mint(user.address, amount);
    await token.connect(user).approve(vault.target, amount);
    await vault.connect(user).deposit(amount);

    const tx = await vault.connect(user).withdraw(amount);
    const receipt = await tx.wait();

    expect(receipt!.gasUsed).to.be.lessThan(45_000n);
  });
});

The gas budget numbers should come from your initial baseline measurements, with a reasonable headroom (typically 10-20%). Too tight and you get false failures from compiler version changes; too loose and regressions slip through.

Gas Snapshots with Foundry

Foundry's gas snapshot system is more ergonomic for tracking gas over time. It creates a .gas-snapshot file that git tracks as part of your codebase.

Create a Foundry test with gas measurement:

// test/VaultGas.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/Vault.sol";
import "./mocks/MockERC20.sol";

contract VaultGasTest is Test {
    Vault vault;
    MockERC20 token;
    address user = makeAddr("user");

    function setUp() public {
        token = new MockERC20("Test", "TEST", 18);
        vault = new Vault(address(token));
        token.mint(user, 10_000 ether);
        vm.prank(user);
        token.approve(address(vault), type(uint256).max);
    }

    function test_deposit_gas() public {
        vm.prank(user);
        vault.deposit(100 ether);
    }

    function test_withdraw_gas() public {
        // Setup
        vm.prank(user);
        vault.deposit(100 ether);

        // Measure withdraw
        vm.prank(user);
        vault.withdraw(100 ether);
    }

    function test_deposit_batch_gas() public {
        // Measure depositing in a loop — stress test for larger operations
        for (uint i = 0; i < 10; i++) {
            vm.prank(user);
            vault.deposit(10 ether);
        }
    }
}

Run the snapshot:

# Create or update the snapshot
forge snapshot

# Compare against the existing snapshot (fails if gas increased)
forge snapshot --check

# Compare and see the diff
forge snapshot --diff

The .gas-snapshot file looks like:

VaultGasTest:test_deposit_gas() (gas: 52413)
VaultGasTest:test_withdraw_gas() (gas: 41022)
VaultGasTest:test_deposit_batch_gas() (gas: 489341)

Commit this file. When someone changes the contract, the --check flag catches regressions:

[FAIL] VaultGasTest:test_deposit_gas()
  Expected: 52413
  Actual:   67891 (+15478 gas, +29.5%)

CI Integration

For Hardhat, add a step that runs tests with gas reporting and fails if a budget-based test fails:

# .github/workflows/gas.yml
name: Gas Regression Tests

on: [push, pull_request]

jobs:
  gas:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - name: Run gas regression tests
        run: REPORT_GAS=true npx hardhat test test/gas/**/*.ts
      - name: Upload gas report
        uses: actions/upload-artifact@v4
        with:
          name: gas-report
          path: gas-report.txt

For Foundry gas snapshots in CI:

- name: Check gas snapshots
  run: forge snapshot --check
  # This will fail if any function's gas usage increased

Add a tolerance for small increases (sometimes compiler upgrades change gas by 1-2%):

# Allow up to 5% increase before failing
forge snapshot --check --tolerance 5

Common Optimization Patterns Worth Benchmarking

Before optimizing, measure. Here are the patterns that reliably reduce gas — benchmark each one before committing to verify the improvement is real in your specific context.

Storage Packing

Solidity stores state variables in 32-byte slots. Multiple smaller variables can share a slot if arranged correctly:

// Unoptimized — 3 storage slots
contract Unoptimized {
    uint256 public value;     // slot 0
    bool public isActive;     // slot 1 (wastes 31 bytes)
    address public owner;     // slot 2 (wastes 12 bytes)
}

// Optimized — 2 storage slots
contract Optimized {
    uint256 public value;     // slot 0
    bool public isActive;     // slot 1 (first 1 byte)
    address public owner;     // slot 1 (next 20 bytes) — packed together
}

Gas saving per SLOAD/SSTORE of isActive + owner together: ~2100 gas (1 cold SLOAD vs 2).

calldata vs memory for Function Parameters

// More expensive — copies array to memory
function processData(uint256[] memory data) external returns (uint256) {
    uint256 sum;
    for (uint i; i < data.length; i++) sum += data[i];
    return sum;
}

// Cheaper — reads directly from calldata
function processData(uint256[] calldata data) external returns (uint256) {
    uint256 sum;
    for (uint i; i < data.length; i++) sum += data[i];
    return sum;
}

For a 100-element array, calldata typically saves 10,000-30,000 gas depending on element size.

Custom Errors vs require Strings

// Expensive — deploys string data, costs gas on revert
require(amount > 0, "Amount must be positive");

// Cheap — 4-byte selector only
error AmountMustBePositive();
revert AmountMustBePositive();

Custom errors typically save 200-500 gas on revert paths and reduce deployment cost.

Caching storage Reads in Loops

// Expensive — reads from storage on every iteration
function expensiveSum() external view returns (uint256) {
    uint256 total;
    for (uint i; i < items.length; i++) {
        total += items[i];
    }
    return total;
}

// Cheap — reads length once, uses stack variable
function cheapSum() external view returns (uint256) {
    uint256 total;
    uint256 len = items.length; // Cache storage read
    for (uint i; i < len;) {
        total += items[i];
        unchecked { ++i; } // No overflow check — saves ~30 gas/iteration
    }
    return total;
}

Events vs Storage for Historical Data

// Expensive: storing all historical prices costs 20,000 gas per entry
uint256[] public priceHistory;

function recordPrice(uint256 price) external {
    priceHistory.push(price); // 20,000+ gas
}

// Cheap: events are much cheaper — ~375 gas per topic
event PriceRecorded(uint256 indexed timestamp, uint256 price);

function recordPrice(uint256 price) external {
    emit PriceRecorded(block.timestamp, price); // ~1500 gas total
}

If you only need historical data for off-chain queries (analytics, UI), events are the right choice. Only use storage if contracts need to read the historical data.

Foundry Gas Benchmarking with vm.startSnapshotGas

Foundry 0.2.0+ supports fine-grained gas snapshotting within tests:

function test_compare_storage_patterns() public {
    address user = makeAddr("user");
    
    // Measure unoptimized version
    uint256 gasStart = gasleft();
    unoptimized.processItems(user, 100);
    uint256 unoptimizedGas = gasStart - gasleft();
    
    // Measure optimized version
    gasStart = gasleft();
    optimized.processItems(user, 100);
    uint256 optimizedGas = gasStart - gasleft();
    
    // Assert optimization actually works
    assertLt(optimizedGas, unoptimizedGas, "Optimized should use less gas");
    
    // Assert specific budget
    assertLt(optimizedGas, 25_000, "Should stay under 25k gas");
    
    console.log("Unoptimized:", unoptimizedGas);
    console.log("Optimized:", optimizedGas);
    console.log("Savings:", unoptimizedGas - optimizedGas);
}

Setting Gas Budgets Across the Codebase

A practical approach for larger projects: define a gas budget file that serves as the source of truth:

// test/gas-budgets.ts
export const GAS_BUDGETS = {
  // Core vault operations
  vault: {
    deposit: 55_000n,
    withdraw: 45_000n,
    depositWithPermit: 70_000n,
  },
  
  // Token operations
  token: {
    transfer: 35_000n,
    approve: 30_000n,
    transferFrom: 40_000n,
  },
  
  // Governance
  governance: {
    propose: 200_000n,
    vote: 80_000n,
    execute: 150_000n,
  },
} as const;

Use these constants in your tests:

import { GAS_BUDGETS } from "./gas-budgets";

it("deposit respects gas budget", async () => {
  const tx = await vault.connect(user).deposit(amount);
  const receipt = await tx.wait();
  expect(receipt!.gasUsed).to.be.lessThan(GAS_BUDGETS.vault.deposit);
});

When you want to intentionally change a budget (legitimate optimization or necessary feature addition), you update the budget file and the change is visible in the PR diff — making it an explicit, reviewed decision.

Deployment Cost Testing

Don't forget deployment gas. Large contracts cost significant ETH to deploy:

it("contract deployment stays within budget", async () => {
  const VaultFactory = await ethers.getContractFactory("Vault");
  const deployTx = await VaultFactory.getDeployTransaction(token.target);
  
  const estimatedGas = await ethers.provider.estimateGas(deployTx);
  
  // Contract should deploy for under 1.5M gas
  expect(estimatedGas).to.be.lessThan(1_500_000n);
});

Or in Foundry:

function test_deployment_cost() public {
    uint256 gasStart = gasleft();
    new Vault(address(token));
    uint256 deployGas = gasStart - gasleft();
    
    assertLt(deployGas, 1_500_000, "Deployment too expensive");
}

Continuous Monitoring for Gas Anomalies

Gas optimization testing covers development-time regressions, but production gas costs can also spike unexpectedly — network congestion, changed behavior in external protocol integrations, user patterns that hit expensive code paths more than expected.

HelpMeTest can monitor your contracts in production and alert when transaction costs for your core functions exceed expected bounds, giving you visibility into gas anomalies that your pre-deployment snapshot tests cannot catch.

Summary

Effective gas optimization requires the same discipline as functional testing: baselines, automation, and regression blocking. The workflow:

  1. Measure first with Hardhat gas reporter or Foundry gas snapshots to establish baselines
  2. Set explicit budgets in your test suite — not documentation, actual assertions
  3. Track snapshots in git so regressions are visible in PRs
  4. Block CI on budget violations — make gas regressions a failing test
  5. Benchmark optimizations before committing — verify the improvement is real
  6. Review budget changes as explicit PR decisions

Gas optimization without measurement is guesswork. Gas testing without CI enforcement is documentation that goes stale. The combination of Foundry snapshots in git and explicit budget assertions in your test suite gives you a system that catches regressions before they reach mainnet.

Read more

Start now free