Tools
Tools: How I Saved 20,000 Gas Per Transaction by Reordering One Line in Solidity
2026-03-01
0 views
admin
The Problem: Silent Storage Slot Waste ## Why the EVM cares ## The Fix: Storage Slot Packing ## The Gas Math ## How to Check Your Own Contracts ## 5 Other Things I Found in the Same Review ## 1. Critical permit.value vulnerability ## 2. Chain ID validation for fork protection ## 3. Fail-fast signature validation ## 4. Custom errors over string reverts ## 5. Dead test code cleanup ## Key Takeaway 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. My state variables looked like this: 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. 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: 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. Move paused right after owner: 4 slots → 3 slots. One fewer storage slot touched at runtime. Here's what this saves in practice: 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. Foundry makes this trivial: This outputs every state variable with its slot number, offset, and byte size. Look for: When Offset > 0, you've got packing happening. When small types have Offset = 0 and their own slot — that's a packing opportunity. Storage packing was the optimization win, but the same code review caught much more: 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. 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. 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. Replaced all require(condition, "String message") with custom errors. Each string revert stores the message in bytecode and costs ~50 extra gas per revert. 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:
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 COMMAND_BLOCK:
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 COMMAND_BLOCK:
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 CODE_BLOCK:
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 CODE_BLOCK:
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 ───────────] CODE_BLOCK:
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 ───────────] COMMAND_BLOCK:
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 COMMAND_BLOCK:
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 COMMAND_BLOCK:
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 CODE_BLOCK:
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 CODE_BLOCK:
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 ─────────] CODE_BLOCK:
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 ─────────] CODE_BLOCK:
forge inspect YourContract storage-layout Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
forge inspect YourContract storage-layout CODE_BLOCK:
forge inspect YourContract storage-layout COMMAND_BLOCK:
| 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 COMMAND_BLOCK:
| 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 | COMMAND_BLOCK:
| 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 | CODE_BLOCK:
// 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 CODE_BLOCK:
// 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()); CODE_BLOCK:
// 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()); CODE_BLOCK:
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 CODE_BLOCK:
function _domainSeparator() internal view returns (bytes32) { if (block.chainid == _CACHED_CHAIN_ID) { return _CACHED_DOMAIN_SEPARATOR; } return _computeDomainSeparator(); // recompute on fork
} CODE_BLOCK:
function _domainSeparator() internal view returns (bytes32) { if (block.chainid == _CACHED_CHAIN_ID) { return _CACHED_DOMAIN_SEPARATOR; } return _computeDomainSeparator(); // recompute on fork
} CODE_BLOCK:
// 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 CODE_BLOCK:
// 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(...)); CODE_BLOCK:
// 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(...)); CODE_BLOCK:
// Before
require(msg.sender == owner, "Not authorized"); // After
error Unauthorized();
if (msg.sender != owner) revert Unauthorized(); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
// Before
require(msg.sender == owner, "Not authorized"); // After
error Unauthorized();
if (msg.sender != owner) revert Unauthorized(); CODE_BLOCK:
// Before
require(msg.sender == owner, "Not authorized"); // After
error Unauthorized();
if (msg.sender != owner) revert Unauthorized(); - 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
how-totutorialguidedev.toai