During my time at KryptoMind LLC, one of my key responsibilities was auditing and optimizing smart contracts. The goal was not to chase clever micro-optimizations. It was to make production contracts safer, easier to reason about, and more efficient for real users. Here’s how to approach that work, with practical examples you can apply to your own contracts.
Understanding Gas: The Hidden Tax
Every operation in Ethereum costs gas. A simple token transfer might cost 21,000 gas, while complex DeFi interactions can consume millions. At 50 gwei gas price and $2,000 ETH, that’s real money:
21,000 gas × 50 gwei × $2,000 / 1,000,000,000 = $2.10
Multiply that by thousands of users, and gas optimization becomes critical.
Anti-Pattern #1: Unnecessary Storage Writes
The Problem: Storage is the most expensive operation in Ethereum.
Before Optimization
// ❌ BAD: Multiple storage writes
contract BadToken {
mapping(address => uint256) public balances;
uint256 public totalSupply;
function transfer(address to, uint256 amount) public {
balances[msg.sender] -= amount; // SSTORE: ~5,000 gas
balances[to] += amount; // SSTORE: ~20,000 gas
totalSupply = totalSupply; // SSTORE: ~5,000 gas (useless!)
}
}
After Optimization
// ✅ GOOD: Eliminated unnecessary write
contract GoodToken {
mapping(address => uint256) public balances;
uint256 public totalSupply;
function transfer(address to, uint256 amount) public {
balances[msg.sender] -= amount; // SSTORE: ~5,000 gas
balances[to] += amount; // SSTORE: ~20,000 gas
// Removed useless totalSupply update
}
}
Savings: ~5,000 gas per transfer
Anti-Pattern #2: Reading Storage Multiple Times
The Problem: Every storage read (SLOAD) costs 2,100 gas in EVM.
Before Optimization
// ❌ BAD: Multiple storage reads
contract BadStaking {
mapping(address => uint256) public stakes;
function calculateReward(address user) public view returns (uint256) {
uint256 reward = 0;
if (stakes[user] > 100 ether) { // SLOAD: 2,100 gas
reward = stakes[user] * 10 / 100; // SLOAD: 2,100 gas (again!)
} else if (stakes[user] > 10 ether) { // SLOAD: 2,100 gas (again!)
reward = stakes[user] * 5 / 100; // SLOAD: 2,100 gas (again!)
}
return reward;
}
}
After Optimization
// ✅ GOOD: Cache storage in memory
contract GoodStaking {
mapping(address => uint256) public stakes;
function calculateReward(address user) public view returns (uint256) {
uint256 stakedAmount = stakes[user]; // SLOAD: 2,100 gas (once)
uint256 reward = 0;
if (stakedAmount > 100 ether) {
reward = stakedAmount * 10 / 100; // MLOAD: 3 gas
} else if (stakedAmount > 10 ether) {
reward = stakedAmount * 5 / 100; // MLOAD: 3 gas
}
return reward;
}
}
Savings: ~6,300 gas per call (3 avoided SLOADs)
Anti-Pattern #3: Using uint8 Instead of uint256
The Counterintuitive Truth: Smaller integers don’t save gas; they cost more!
Before Optimization
// ❌ BAD: Using uint8 thinking it saves gas
contract BadCounter {
uint8 public count = 0;
function increment() public {
count += 1; // Extra gas for conversion and masking!
}
}
After Optimization
// ✅ GOOD: Use uint256 (EVM's native word size)
contract GoodCounter {
uint256 public count = 0;
function increment() public {
count += 1; // Optimized for EVM
}
}
Why? The EVM operates on 256-bit words. Using smaller types requires extra operations to mask unused bits.
Exception: Pack multiple variables in a single slot:
// ✅ GOOD: Struct packing saves storage
struct User {
uint128 balance; // First slot (128 bits)
uint128 reward; // First slot (128 bits) - SHARED!
uint256 lastClaim; // Second slot (256 bits)
}
// Uses 2 storage slots instead of 3
Anti-Pattern #4: Unbounded Loops
The Problem: Loops that grow with array size can exceed gas limits.
Before Optimization
// ❌ BAD: Unbounded loop
contract BadAirdrop {
address[] public recipients;
function airdrop(uint256 amount) public {
// What if recipients has 10,000 addresses?
for (uint256 i = 0; i < recipients.length; i++) {
transfer(recipients[i], amount);
}
}
}
After Optimization
// ✅ GOOD: Paginated processing
contract GoodAirdrop {
address[] public recipients;
uint256 public lastProcessed = 0;
function airdrop(uint256 amount, uint256 batchSize) public {
uint256 end = lastProcessed + batchSize;
if (end > recipients.length) {
end = recipients.length;
}
for (uint256 i = lastProcessed; i < end; i++) {
transfer(recipients[i], amount);
}
lastProcessed = end;
}
}
Better Alternative: Use a pull pattern (users claim instead of push):
// ✅ BEST: Pull pattern
contract BestAirdrop {
mapping(address => uint256) public allocations;
mapping(address => bool) public claimed;
function claim() public {
require(!claimed[msg.sender], "Already claimed");
require(allocations[msg.sender] > 0, "No allocation");
claimed[msg.sender] = true;
transfer(msg.sender, allocations[msg.sender]);
}
}
Anti-Pattern #5: String Comparisons
The Problem: Strings are expensive to compare.
Before Optimization
// ❌ BAD: String comparison
contract BadRoles {
mapping(address => string) public roles;
function isAdmin(address user) public view returns (bool) {
// Very expensive!
return keccak256(bytes(roles[user])) == keccak256(bytes("admin"));
}
}
After Optimization
// ✅ GOOD: Use bytes32 or enums
contract GoodRoles {
enum Role { None, User, Admin }
mapping(address => Role) public roles;
function isAdmin(address user) public view returns (bool) {
return roles[user] == Role.Admin; // Cheap integer comparison
}
}
Real-World Example: Optimized ERC20 Token
Here’s a production-grade ERC20 implementation with optimizations:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract OptimizedToken {
string public constant name = "Optimized Token";
string public constant symbol = "OPT";
uint8 public constant decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(uint256 _initialSupply) {
totalSupply = _initialSupply;
balanceOf[msg.sender] = _initialSupply;
emit Transfer(address(0), msg.sender, _initialSupply);
}
function transfer(address to, uint256 amount) external returns (bool) {
return _transfer(msg.sender, to, amount);
}
function transferFrom(address from, address to, uint256 amount)
external
returns (bool)
{
uint256 allowed = allowance[from][msg.sender];
require(allowed >= amount, "Insufficient allowance");
// Update allowance before transfer (checks-effects-interactions)
if (allowed != type(uint256).max) {
allowance[from][msg.sender] = allowed - amount;
}
return _transfer(from, to, amount);
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function _transfer(address from, address to, uint256 amount)
private
returns (bool)
{
require(to != address(0), "Transfer to zero address");
// Cache balances in memory
uint256 fromBalance = balanceOf[from];
require(fromBalance >= amount, "Insufficient balance");
// Unchecked math after explicit balance checks
unchecked {
balanceOf[from] = fromBalance - amount;
balanceOf[to] += amount;
}
emit Transfer(from, to, amount);
return true;
}
}
Key Optimizations:
- Constants: name, symbol, decimals (saves 2,100 gas per read)
- Cached storage: Read balanceOf once, use memory
- Unchecked math: Safe after require checks
- Infinite approval: Skip update if max uint256
Security: The Non-Negotiable
Gas optimization should never compromise security. Here are critical security patterns:
Reentrancy Protection
// ✅ Use checks-effects-interactions pattern
function withdraw(uint256 amount) public {
// Checks
require(balances[msg.sender] >= amount, "Insufficient balance");
// Effects
balances[msg.sender] -= amount;
// Interactions (external calls last)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
// ✅ Or use ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Safe is ReentrancyGuard {
function withdraw(uint256 amount) public nonReentrant {
// Safe from reentrancy
}
}
Integer Overflow Protection
// ✅ Solidity 0.8+ has built-in overflow checks
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b; // Reverts on overflow
}
// ✅ Use unchecked only when safe
function addSafe(uint256 a, uint256 b) public pure returns (uint256) {
unchecked {
return a + b; // Only if you've proven no overflow possible
}
}
Access Control
// ✅ Use OpenZeppelin's Ownable or AccessControl
import "@openzeppelin/contracts/access/Ownable.sol";
contract Protected is Ownable {
function criticalFunction() public onlyOwner {
// Only owner can call
}
}
Testing & Auditing
Before deploying, always:
1. Gas Profiling
// hardhat.config.js
module.exports = {
gasReporter: {
enabled: true,
currency: "USD",
gasPrice: 50,
},
};
2. Unit Tests
describe("OptimizedToken", function () {
it("Should transfer tokens efficiently", async function () {
const [owner, addr1] = await ethers.getSigners();
const Token = await ethers.getContractFactory("OptimizedToken");
const token = await Token.deploy(1000);
const tx = await token.transfer(addr1.address, 100);
const receipt = await tx.wait();
console.log("Gas used:", receipt.gasUsed.toString());
expect(await token.balanceOf(addr1.address)).to.equal(100);
});
});
3. External Audits
For production contracts handling real value:
- Certik: Comprehensive audits
- OpenZeppelin: Trusted auditors
- Trail of Bits: Security experts
The Results
After improving the smart-contract implementation:
- Clearer security boundaries around balance updates and privileged operations
- More predictable transaction costs by removing unnecessary storage work
- Better auditability through simpler control flow and explicit checks
- Better code maintainability for future product changes
Key Takeaways
- Cache storage reads: SLOAD costs 2,100 gas; MLOAD costs 3 gas
- Minimize storage writes: SSTORE costs 5,000-20,000 gas
- Use uint256: EVM’s native word size
- Avoid unbounded loops: Use pagination or pull patterns
- Security first: Never sacrifice correctness for micro-optimizations
- Test everything: Gas profiling + comprehensive tests
- Get audited: External audits for production contracts
Tools & Resources
- Remix: Browser-based IDE with gas profiling
- Hardhat: Development environment with gas reporter
- Foundry: Fast testing framework
- Slither: Static analysis tool
- MythX: Automated security analysis
Smart contract optimization is about balance: minimize gas without compromising security or readability. Always measure, test, and audit.
Building on Ethereum or other EVM chains? I’d love to discuss your smart contract optimization challenges.