Gas Optimization skill for Codex
Cut storage costs first, prove every saving. Codex baselines with forge snapshot and gas reports, ranks functions by cost times call volume, and applies one change at a time, starting with storage packing, caching and immutables rather than syntax tricks. Each saving is kept only if tests pass and the snapshot proves it, and a bundled script diffs two snapshots with a regression threshold for CI.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- gas-optimization Installs to ~/.agents/skills/gas-optimization/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install gas-optimization The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project gas-optimization Installs to .agents/skills/gas-optimization/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/gas-optimization.zipunzip -o gas-optimization.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) gas-optimization PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $gas-optimization, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Reduces Solidity gas costs by measurement - forge snapshot and gas reports before and after, storage packing, immutable and constant, caching SLOADs, calldata over memory, custom errors, transient storage, bitmaps, events instead of storage, optimizer runs and via-IR trade-offs - without trading away safety or readability for noise-level savings. Use when asked to make a contract cheaper to deploy or call, review a gas report, cut costs for a hot function, tune optimizer settings, or gate gas regressions in CI.
Use it when
- A hot contract function costs users too much gas
- A gas report or snapshot diff needs interpretation
- Optimizer runs, via-IR or evm_version settings need choosing
- You want gas regressions caught in pull requests
Use something else when
- You need a security review of the contract: use smart-contract-audit
- The cost problem is off-chain RPC or indexing load: use thegraph-subgraphs
How to check the result
Snapshots before and after show the per-function delta, the full test suite passes, and storage layout output is unchanged for upgradeable contracts or the change is documented.
The mistake it prevents
Spending effort on micro-syntax like ++i or != 0 while a loop still reads the same storage slot on every iteration.
An optimization counts only when a snapshot proves it, the tests still pass, and the saving matters at the function’s real call volume. Storage access dominates almost every bill; fix that before touching syntax. Never weaken a check, remove an event users rely on, or add assembly to save a few hundred gas on a function called once.
Workflow
- [ ] 1. Read AGENTS.md, foundry.toml / hardhat config (optimizer, runs, via_ir, evm_version)
- [ ] 2. Make sure tests exercise the hot paths with realistic state (warm vs cold matters)
- [ ] 3. Baseline: forge snapshot (commit it) and forge test --gas-report
- [ ] 4. Rank functions by cost x expected call volume; pick the top few
- [ ] 5. Apply one change at a time from the table below; re-snapshot after each
- [ ] 6. Keep a change only if tests pass and the saving is real; revert noise
- [ ] 7. Report before/after per function with the reasoning
forge snapshot # writes .gas-snapshot
forge test --gas-report # per-function min/avg/median/max, deploy cost
forge snapshot --diff .gas-snapshot # compare current run with the saved file
forge snapshot --check # non-zero exit if anything changed (CI gate)
forge build --sizes # runtime and initcode size vs limits
For a readable diff between two saved snapshots with a regression threshold, run
python3 scripts/gas_diff.py before.snap after.snap --threshold 1 (exit 1 when any test
regresses more than the threshold). Save the baseline with
git show main:.gas-snapshot > before.snap.
In Hardhat projects use hardhat-gas-reporter (bundled in the Hardhat 2 toolbox) or run the
Solidity tests through Foundry for snapshots.
Cost model to reason with
| Operation | Approx. gas | Note |
|---|---|---|
| SSTORE zero to non-zero | 22,100 cold | The single most expensive common op |
| SSTORE non-zero to non-zero | 5,000 cold / 2,900 warm | |
| SLOAD | 2,100 cold / 100 warm | Cold = first access in the transaction |
| TSTORE / TLOAD (EIP-1153) | 100 | Cleared at end of transaction |
| Access a cold external address | 2,600 | CALL, BALANCE, EXTCODESIZE |
| LOG with one topic | 375 + 375 + 8 per byte | Far cheaper than storage for history |
| Calldata byte | 16 non-zero / 4 zero | On rollups, L1 data cost dominates instead |
| Storage refund | capped at 1/5 of tx gas | Clearing storage is not free money |
Changes ranked by typical payoff
| Change | When it pays | Watch out |
|---|---|---|
| Pack storage: order fields so small types share a 32-byte slot | Structs and state read or written together | Only helps when packed fields are accessed together |
constant / immutable for values fixed at compile time or deploy | Always | Immutables cannot be changed by upgrades of the same proxy implementation |
| Cache storage in a local variable inside a function | A slot read more than once | Write back once at the end |
| Avoid writing unchanged values; avoid zero-to-nonzero flips | Flags, counters that reset | Keep a non-zero sentinel (1/2 instead of 0/1) where it saves a 22k write |
Bitmaps (BitMaps, uint256 masks) for booleans | Claimed flags, allowlists | Readability; document bit layout |
| Events instead of storage for data never read on-chain | History, receipts | Off-chain consumers must index logs |
| Mappings instead of arrays for lookups | Membership checks | Lose iteration; keep a count if needed |
calldata for external array and string params | Read-only inputs | Cannot modify calldata |
| Custom errors instead of revert strings | Every revert path | Already the default in modern code |
| Transient storage for reentrancy locks and per-tx flags | Chains supporting Cancun | Value must not need to survive the transaction |
| Merkle proofs instead of on-chain allowlists | Large allowlists | Users supply proofs |
| Minimal proxies (ERC-1167 clones) for many identical contracts | Factories | Extra DELEGATECALL per call (about 2.6k cold) |
| Batch operations (multicall, batch transfer) | Users doing many small calls | Bound batch size |
Packing example
// Before: 3 slots. owner (slot 0), amount (slot 1), openedAt + closed (slot 2)
struct Position { address owner; uint256 amount; uint64 openedAt; bool closed; }
// After: 2 slots. amount (slot 0), owner + openedAt + closed = 29 bytes (slot 1)
struct Position { uint256 amount; address owner; uint64 openedAt; bool closed; }
A uint256 between small fields forces each small field into its own slot. Verify the
layout instead of guessing:
forge inspect Vault storageLayout
Pick widths that cannot overflow in the protocol’s lifetime (uint64 seconds lasts beyond
any deployment; uint96 amounts cap at about 7.9e28 wei) and cast with SafeCast.
Caching example
// Before: reads `totalStaked` and `rewardRate` from storage on every iteration
for (uint256 i; i < users.length; i++) {
rewards[users[i]] += stakes[users[i]] * rewardRate / totalStaked;
}
// After: two SLOADs total, calldata array
function distribute(address[] calldata users) external onlyKeeper {
uint256 rate = rewardRate;
uint256 total = totalStaked;
uint256 n = users.length;
for (uint256 i; i < n; ++i) {
address u = users[i];
rewards[u] += stakes[u] * rate / total;
}
}
Compiler settings
| Setting | Guidance |
|---|---|
optimizer_runs | Low (200 or less) favours deploy size; high (10,000+) favours runtime for hot contracts. Measure both with --gas-report |
via_ir = true | Often cheaper runtime and fixes stack-too-deep; slower compiles. Re-run the full suite and diff bytecode size after switching |
evm_version | Newer versions enable cheaper opcodes (PUSH0, MCOPY, TSTORE) but must be supported on every target chain |
| Solidity version | 0.8.22+ removes overflow checks on simple for loop increments automatically; manual unchecked { ++i; } is no longer needed there |
Changing optimizer settings on an already-deployed upgradeable contract changes bytecode but not storage; still re-run the full suite and storage-layout checks.
Micro-optimizations: usually skip
!= 0 vs > 0, ++i vs i++, external vs public, and short-circuit ordering save
single-digit to tens of gas with modern solc. Apply them only in loops that run many times,
and only when a snapshot shows the difference. Inline assembly is a last resort: it removes
compiler safety checks, needs a comment explaining the memory and stack assumptions, and must
have dedicated fuzz tests.
L2 considerations
On rollups, execution gas is cheap and the L1 data fee is often the larger part of a user’s
cost. Measure on the target chain (or its fork with the right chainType) before optimizing
execution. Reducing calldata (tighter argument types, packed encodings) matters more there
than saving SLOADs.
Hard rules
- Never remove access control, input validation, reentrancy guards or events to save gas.
- Never use
uncheckedwithout a comment proving the bound. - Each optimization is its own commit with its snapshot delta in the message.
- A gas regression gate in CI uses
forge snapshot --checkor the diff script with a tolerance; do not make every one-gas change a failure.
Report template
**Baseline:** main @ abc123, solc 0.8.28, optimizer 200 runs
| Function | Before | After | Delta | Change |
|---|---|---|---|---|
| deposit | 71,240 | 52,110 | -26.9% | packed Position, cached rewardRate |
| claim | 48,900 | 46,700 | -4.5% | bitmap for claimed flags |
**Deploy cost:** 1,412,000 -> 1,380,500
**Rejected:** via_ir (runtime -1%, compile time x4; not worth it here)
**Tests:** all pass; storage layout unchanged / changed (non-upgradeable) What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Gas Optimization"
short_description: "Cut storage costs first, prove every saving"
default_prompt: "$gas-optimization Make deposit and claim in this contract cheaper without changing behaviour" scripts/gas_diff.py
#!/usr/bin/env python3
"""Compare two Foundry .gas-snapshot files and flag regressions.
Usage:
python3 gas_diff.py BEFORE AFTER [--threshold PCT] [--all]
BEFORE and AFTER are .gas-snapshot files (for example, one saved from main with
`git show main:.gas-snapshot > /tmp/before.snap`). For fuzz tests the median (~)
is compared; for plain tests the single gas value. Invariant entries carry no
gas figure and are skipped.
Exit codes: 0 = no test regressed by more than the threshold, 1 = at least one
regression above the threshold, 2 = usage or parse error.
"""
from __future__ import annotations
import argparse
import re
import sys
LINE = re.compile(r"^(?P<name>\S+\(.*?\))\s+\((?P<body>.*)\)\s*$")
GAS = re.compile(r"gas:\s*(\d+)")
MEDIAN = re.compile(r"~:\s*(\d+)")
def parse(path: str) -> dict[str, int]:
out: dict[str, int] = {}
try:
with open(path, encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if not line:
continue
m = LINE.match(line)
if not m:
continue
body = m.group("body")
g = GAS.search(body) or MEDIAN.search(body)
if g:
out[m.group("name")] = int(g.group(1))
except OSError as exc:
print(f"error: cannot read {path}: {exc}", file=sys.stderr)
sys.exit(2)
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("before")
ap.add_argument("after")
ap.add_argument("--threshold", type=float, default=1.0,
help="percent increase that counts as a regression (default 1.0)")
ap.add_argument("--all", action="store_true", help="also list unchanged tests")
args = ap.parse_args()
before, after = parse(args.before), parse(args.after)
if not before or not after:
print("error: one of the snapshots has no parseable gas entries", file=sys.stderr)
return 2
rows = []
for name in sorted(set(before) | set(after)):
b, a = before.get(name), after.get(name)
if b is None:
rows.append((name, "-", a, "", "new"))
continue
if a is None:
rows.append((name, b, "-", "", "removed"))
continue
delta = a - b
pct = (delta / b * 100) if b else 0.0
if pct > args.threshold:
status = "REGRESSION"
elif delta < 0:
status = "improved"
elif delta > 0:
status = "up (within threshold)"
else:
status = "same"
if status == "same" and not args.all:
continue
rows.append((name, b, a, f"{delta:+d} ({pct:+.2f}%)", status))
regressions = [r for r in rows if r[4] == "REGRESSION"]
total_b = sum(before[n] for n in before if n in after)
total_a = sum(after[n] for n in after if n in before)
if rows:
w = max(len(r[0]) for r in rows)
print(f"{'test'.ljust(w)} {'before':>10} {'after':>10} {'delta':>20} status")
for name, b, a, d, s in rows:
print(f"{name.ljust(w)} {str(b):>10} {str(a):>10} {d:>20} {s}")
else:
print("no changes")
if total_b:
print(f"\nshared tests total: {total_b} -> {total_a} "
f"({(total_a - total_b) / total_b * 100:+.2f}%)")
print(f"regressions above {args.threshold}%: {len(regressions)}")
return 1 if regressions else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.