Back to blog
Aug 28, 2024
8 min read
Muhammad Waqar Ilyas

Smart Contract Security: Lessons from Reducing Gas Fees by 30%

Practical insights into optimizing smart contracts for cost and security, with real code examples from auditing and improving production contracts on Ethereum and BSC.

During my time at KryptoMind LLC, one of my key responsibilities was auditing and optimizing smart contracts. Through careful analysis and refactoring, we reduced gas fees by 30% for end users while simultaneously improving security. Here’s how we did it, with real 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 for gas savings (safe after checks)
        unchecked {
            balanceOf[from] = fromBalance - amount;
            balanceOf[to] += amount;
        }

        emit Transfer(from, to, amount);
        return true;
    }
}

Key Optimizations:

  1. Constants: name, symbol, decimals (saves 2,100 gas per read)
  2. Cached storage: Read balanceOf once, use memory
  3. Unchecked math: Safe after require checks
  4. 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 optimizing our smart contracts:

  • 30% reduction in average gas fees
  • Zero security vulnerabilities in audits
  • Higher user adoption due to lower costs
  • Better code maintainability

Key Takeaways

  1. Cache storage reads: SLOAD costs 2,100 gas; MLOAD costs 3 gas
  2. Minimize storage writes: SSTORE costs 5,000-20,000 gas
  3. Use uint256: EVM’s native word size
  4. Avoid unbounded loops: Use pagination or pull patterns
  5. Security first: Never sacrifice security for gas savings
  6. Test everything: Gas profiling + comprehensive tests
  7. 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.