DEV Community

Cover image for How I Saved 20,000 Gas Per Transaction by Reordering One Line in Solidity
Dhruv Sharma
Dhruv Sharma

Posted on

How I Saved 20,000 Gas Per Transaction by Reordering One Line in Solidity

While building a smart wallet contract for Fishnet — an AI agent transaction security proxy — I ran a self-imposed code review and found a subtle optimization that every Solidity developer should know about.

One variable reorder. 20,000 gas saved per transaction.

Here's the full breakdown.


The Problem: Silent Storage Slot Waste

My state variables looked like this:

address public owner;          // 20 bytes → Slot 0
address public fishnetSigner;  // 20 bytes → Slot 1
mapping(uint256 => bool) public usedNonces; // Slot 2
bool public paused;            // 1 byte  → Slot 3  ← wasting 31 bytes
Enter fullscreen mode Exit fullscreen mode

That bool paused at the bottom? It's only 1 byte, but it was consuming an entire 32-byte storage slot. That's 31 bytes of wasted space — and more importantly, an extra SLOAD/SSTORE on every pause check.

Why the EVM cares

The EVM operates on 32-byte words. Every storage slot is exactly 32 bytes. When the Solidity compiler lays out your state variables, it goes top to bottom in declaration order:

Slot 0: [owner ─────────────── 20 bytes][── 12 bytes empty ──]
Slot 1: [fishnetSigner ─────── 20 bytes][── 12 bytes empty ──]
Slot 2: [usedNonces mapping hash ───────────────── 32 bytes ─]
Slot 3: [paused ─ 1 byte][─────── 31 bytes empty ───────────]
Enter fullscreen mode Exit fullscreen mode

The compiler does not reorder your variables for you. If a variable can't fit in the remaining space of the current slot, it starts a new one. An address is 20 bytes. A bool is 1 byte. They fit together with 11 bytes to spare — but only if they're adjacent in your declaration.


The Fix: Storage Slot Packing

Move paused right after owner:

address public owner;          // 20 bytes ─┐
bool public paused;            // 1 byte  ──┘ Slot 0 (21/32 bytes)
address public fishnetSigner;  // 20 bytes → Slot 1
mapping(uint256 => bool) public usedNonces; // Slot 2
Enter fullscreen mode Exit fullscreen mode

New layout:

Slot 0: [owner ─────────────── 20 bytes][paused 1B][─ 11 bytes empty ─]
Slot 1: [fishnetSigner ─────── 20 bytes][── 12 bytes empty ──────────]
Slot 2: [usedNonces mapping hash ───────────────── 32 bytes ─────────]
Enter fullscreen mode Exit fullscreen mode

4 slots → 3 slots. One fewer storage slot touched at runtime.

EVM Storage Slot Packing - Before and After


The Gas Math

Here's what this saves in practice:

Operation Before (separate slots) After (packed) Savings
Cold SLOAD (first read in tx) 2,100 gas × 2 slots 2,100 gas × 1 slot 2,100 gas
Cold SSTORE (pause/unpause) ~20,000 gas 0 (slot already warm from owner) ~20,000 gas
whenNotPaused modifier per call Reads its own slot Reads owner's slot (often already warm) Up to 2,000 gas

The big win is the cold SSTORE elimination. Writing to a storage slot that hasn't been accessed in the current transaction costs ~20,000 gas. But if owner has already been read (which it almost always has in the same transaction context), the slot containing paused is now warm — and a warm SSTORE costs only ~2,900 gas.


How to Check Your Own Contracts

Foundry makes this trivial:

forge inspect YourContract storage-layout
Enter fullscreen mode Exit fullscreen mode

This outputs every state variable with its slot number, offset, and byte size. Look for:

  • Variables that could pack together (combined size ≤ 32 bytes) but are in separate slots
  • bool, uint8, uint16, address separated by mappings or larger types
  • Related variables read together that are in different slots

Example output:

| Name          | Type                        | Slot | Offset | Bytes |
|---------------|-----------------------------|------|--------|-------|
| owner         | address                     | 0    | 0      | 20    |
| paused        | bool                        | 0    | 20     | 1     |
| fishnetSigner | address                     | 1    | 0      | 20    |
| usedNonces    | mapping(uint256 => bool)    | 2    | 0      | 32    |
Enter fullscreen mode Exit fullscreen mode

When Offset > 0, you've got packing happening. When small types have Offset = 0 and their own slot — that's a packing opportunity.


5 Other Things I Found in the Same Review

Storage packing was the optimization win, but the same code review caught much more:

1. Critical permit.value vulnerability

The execute() function accepted a permit signature but never validated that permit.value matched msg.value. An attacker could get a permit signed for 0.01 ETH but submit the transaction with 100 ETH, draining the wallet.

// Before: no validation
function execute(Permit calldata permit, ...) external payable {
    // permit.value could be anything vs msg.value
}

// After: explicit check
require(permit.value == msg.value, InsufficientValue());
Enter fullscreen mode Exit fullscreen mode

2. Chain ID validation for fork protection

The contract cached DOMAIN_SEPARATOR at deployment but never recomputed it. On a chain fork (like ETH/ETH Classic), signatures from one chain would be valid on the other.

function _domainSeparator() internal view returns (bytes32) {
    if (block.chainid == _CACHED_CHAIN_ID) {
        return _CACHED_DOMAIN_SEPARATOR;
    }
    return _computeDomainSeparator(); // recompute on fork
}
Enter fullscreen mode Exit fullscreen mode

3. Fail-fast signature validation

The original code ran an expensive keccak256 hash before checking if the signature was even the right length. Flipping the order saves gas on every invalid input.

// Before: hash first, then check length
bytes32 hash = keccak256(abi.encodePacked(...));
require(signature.length == 65, InvalidSignature());

// After: check length first, hash only if valid
require(signature.length == 65, InvalidSignature());
bytes32 hash = keccak256(abi.encodePacked(...));
Enter fullscreen mode Exit fullscreen mode

4. Custom errors over string reverts

Replaced all require(condition, "String message") with custom errors. Each string revert stores the message in bytecode and costs ~50 extra gas per revert.

// Before
require(msg.sender == owner, "Not authorized");

// After
error Unauthorized();
if (msg.sender != owner) revert Unauthorized();
Enter fullscreen mode Exit fullscreen mode

5. Dead test code cleanup

Found leftover console.log imports and unused test helper functions that had accumulated during rapid iteration. They don't affect runtime gas, but they bloat deployment bytecode.


Key Takeaway

Code review isn't just about finding bugs. It's about understanding the machine your code runs on.

The EVM has a 32-byte word size, and every storage slot costs real money. Knowing how the compiler lays out storage is the difference between a contract that costs users $2 per transaction and one that costs $5.

Run forge inspect YourContract storage-layout. Look at your slot assignments. You might be surprised what you find.


This came out of building Fishnet — an open-source security proxy for AI agent transactions on Ethereum. If you're working on AI × Web3 infra, check it out.

Top comments (0)