All posts
SolidityEthereumWeb3

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

Dhruv SharmaMar 1, 20264 min read

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

solidity
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

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.

text
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 -----------]

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

solidity
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
text
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 -----]

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

EVM Storage Slot Packing — Before and After
EVM Storage Slot Packing — Before and After

The Gas Math

OperationBeforeAfterSavings
Cold SLOAD (first read in tx)2,100 gas × 2 slots2,100 gas × 1 slot2,100 gas
Cold SSTORE (pause/unpause)~20,000 gas0 (slot already warm from owner)~20,000 gas
whenNotPaused modifier per callReads its own slotReads 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:

bash
forge inspect YourContract storage-layout

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, and related variables read together that are in different slots.

text
| 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    |

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 Found in the Same Review

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.

solidity
// 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());

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.

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

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.

solidity
// 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(...));

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.

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

// After
error Unauthorized();
if (msg.sender != owner) revert Unauthorized();

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.