🌱 Beginner ⏱️ 20 min

Gas: The Cost of Computation

Every operation on Ethereum costs gas. Understanding gas is not just about saving money — it is about understanding an entire class of security vulnerabilities. DoS attacks, gas griefing, reentrancy via .call, and block stuffing all exploit the gas system. This lesson covers how gas works from the ground up and how attackers weaponize it.

Why Gas Exists

Without a cost for computation, the Ethereum network would be trivially easy to spam. Consider what would happen:

🤔 Without Gas (Hypothetical Attack)
// Attacker deploys this contract with no gas cost: contract Spammer { function attack() external { while(true) { // Infinite loop! // Every Ethereum node must execute this forever // The network grinds to a halt } } } // With gas: // The infinite loop costs gas per iteration // Gas runs out → transaction reverts → attacker still paid // This makes spam economically irrational

Gas serves three purposes:

  • Spam prevention: Every computation costs real money — infinite loops cost infinite money and revert
  • Resource allocation: The gas limit ensures no single transaction can monopolize the network
  • Validator compensation: Gas fees pay the validators who process your transactions

Gas Units vs Gas Price vs Total Cost

ConceptWhat It IsWho Sets ItExample Value
Gas UnitsThe amount of computational workThe EVM (fixed per opcode)21,000 units (simple ETH transfer)
Base FeeMinimum price per gas unit (burned)Protocol (automatically adjusted)15 gwei per unit
Priority FeeTip to validator (they keep this)You (the sender)2 gwei per unit
Max FeeMaximum you'll pay per gas unitYou (the sender)30 gwei per unit
Total CostUnits x (baseFee + priorityFee)Calculated at inclusion time21,000 x 17 gwei = 0.000357 ETH
🧮 Gas Cost Calculation — Worked Example
Scenario: You send 1 ETH to a friend during normal network activity Transaction parameters: gasLimit = 21,000 (ETH transfer always costs exactly 21,000) baseFee = 15 gwei (set by the protocol, you cannot control this) priorityFee = 2 gwei (your tip to the validator) maxFee = 30 gwei (safety cap you set) Actual gas price paid = baseFee + priorityFee = 15 + 2 = 17 gwei Total gas cost: = gasUsed x (baseFee + priorityFee) = 21,000 x 17 gwei = 357,000 gwei = 0.000357 ETH (~$1.07 at $3,000/ETH) What happens to the fee: baseFee portion (315,000 gwei) → BURNED forever (deflationary) priorityFee portion (42,000 gwei) → VALIDATOR keeps this

EIP-1559: The Fee Market Reform

Before EIP-1559 (August 2021), users blindly guessed gas prices, causing massive overpayments. EIP-1559 introduced the dynamic baseFee model:

🔄 EIP-1559 Base Fee Adjustment Algorithm
Block gas limit: 30,000,000 gas maximum Target usage: 15,000,000 gas (50% of limit) Base fee adjustment rule: Block > 50% full: baseFee INCREASES up to 12.5% next block Block < 50% full: baseFee DECREASES up to 12.5% next block Block = 50% full: baseFee unchanged Example: baseFee = 15 gwei, block is 100% full (30M gas) Next baseFee = 15 * 1.125 = 16.875 gwei Security implication: If an attacker fills every block to 100%: → baseFee doubles roughly every 8 blocks → After 50 blocks (~10 min): baseFee is astronomical → Normal users priced out → This is a "block stuffing" DoS attack → But attacker pays too — it's a very expensive attack

Common EVM Opcodes and Their Gas Costs

OpcodeOperationGas CostNotes
ADD, SUBInteger arithmetic3Very cheap
MUL, DIVMultiplication/division5Very cheap
MLOAD, MSTORERead/write memory3 base + expansionMemory expands quadratically
SLOADRead from storage100 warm / 2,100 coldVery expensive vs memory
SSTORE (new slot)Write non-zero to zero slot20,000Most expensive common op
SSTORE (update)Change existing non-zero value2,900Much cheaper than new slot
SSTORE (zero out)Set slot to zero2,900 + 4,800 refundGives gas refund
CALLExternal call to contract100 base + forwarded gasExtra 9,000 for value transfer
CREATEDeploy new contract32,000 + bytecode costVery expensive
LOG (event)Emit an event375 + 8 per byteCheaper than storage
KECCAK256Hash 32 bytes30 + 6 per wordCommon in mapping lookups
Base tx costSimple ETH transfer21,000 flatMinimum transaction cost
💡 Key Insight: Storage Is Brutally Expensive

Writing to a new storage slot (SSTORE) costs 20,000 gas — roughly 6,667 times more than an ADD operation. This is why gas-optimized code minimizes storage writes, caches values in memory variables, and avoids patterns that touch storage inside loops.

Out-of-Gas: What Happens When You Run Out

⛽ Out-of-Gas Execution Flow
Transaction starts with gasLimit = 50,000 EVM begins executing: ADD → 3 gas used (running total: 3) SLOAD → 2,100 gas (running total: 2,103) SSTORE → 20,000 gas (running total: 22,103) CALL → 2,300 gas (running total: 24,403) SSTORE → 20,000 gas (running total: 44,403) SLOAD → 2,100 gas (running total: 46,503) SSTORE → 20,000 gas → EXCEEDS LIMIT OF 50,000! At this point: ALL state changes are REVERTED (atomic rollback) Storage writes, ETH transfers — all undone BUT: the gas consumed (50,000 units) is STILL CHARGED You pay for failed transactions — no exceptions Refund: Any gas NOT consumed is returned In this case: 0 refunded (ran out completely)

Security Vulnerabilities That Exploit Gas

1. DoS via Unbounded Loops

🚨 Vulnerability: Unbounded Loop DoS

If a function loops over a dynamically-sized array and that array can be grown by attackers, the function can eventually exceed the block gas limit and become permanently uncallable.

💥 Unbounded Loop DoS — Exploit + Fix
// VULNERABLE: Anyone can add to the array, making distribute() uncallable contract VulnerableDistributor { address[] public recipients; function addRecipient(address addr) external { recipients.push(addr); // Attacker can call this 10,000 times } function distribute() external { for (uint i = 0; i < recipients.length; i++) { // With 10,000 recipients: 10,000 * ~5,000 gas = 50M gas // Block gas limit is 30M → this function NEVER executes! payable(recipients[i]).transfer(1 ether); } } } // SECURE: Use pull payments instead of push contract SecureDistributor { mapping(address => uint256) public pendingWithdrawals; function allocate(address recipient, uint256 amount) external { pendingWithdrawals[recipient] += amount; // O(1) — no loop } function withdraw() external { uint256 amount = pendingWithdrawals[msg.sender]; pendingWithdrawals[msg.sender] = 0; payable(msg.sender).transfer(amount); // Each user withdraws themselves } }

2. Gas Griefing in Relay Contracts

💥 Gas Griefing Attack
// Relay contract that lets users submit transactions on behalf of others contract Relayer { function relay( address target, bytes calldata data, uint256 gasLimit ) external { // VULNERABLE: Relayer (msg.sender) can provide insufficient gas // The inner call fails with OOG, but the outer tx succeeds // The original user's transaction is permanently "failed" // but the relayer still got paid for submitting it! target.call{gas: gasLimit}(data); } // FIX: Verify enough gas is being forwarded function relaySecure( address target, bytes calldata data, uint256 requiredGas ) external { require(gasleft() >= requiredGas * 64 / 63 + 1000); target.call{gas: requiredGas}(data); } }

3. Reentrancy via .call Forwarding All Gas

💥 Gas Forwarding Enables Reentrancy
// Old pattern: transfer() and send() forward only 2,300 gas // 2,300 gas is NOT enough for state-changing operations // This was thought to prevent reentrancy address.transfer(amount); // 2,300 gas stipend — reentrancy mostly prevented address.send(amount); // 2,300 gas stipend — reentrancy mostly prevented // New pattern: call() forwards ALL remaining gas by default // This ENABLES reentrancy attacks! (bool success, ) = address.call{value: amount}(""); // Forwards ALL gas! // The Checks-Effects-Interactions pattern protects against this: function withdraw(uint256 amount) external { // 1. CHECKS first require(balances[msg.sender] >= amount); // 2. EFFECTS (update state BEFORE external call) balances[msg.sender] -= amount; // 3. INTERACTIONS last (external call after state is updated) (bool ok, ) = msg.sender.call{value: amount}(""); require(ok); // Even if msg.sender reenters withdraw(), their balance is already 0 }

4. Block Gas Limit DoS

💥 Block Gas Limit Permanent DoS
// Pattern: Function that cannot fit in a single block contract AuctionManager { Auction[] public auctions; // Grows unboundedly // When there are 1000+ auctions, this will always revert // because it exceeds the 30M block gas limit function endAllAuctions() external { for (uint i = 0; i < auctions.length; i++) { auctions[i].finalize(); // Each finalize() costs ~50,000 gas // 1000 auctions * 50,000 = 50,000,000 gas > 30,000,000 limit } } } // FIX: Process in batches function endAuctions(uint256 startIndex, uint256 count) external { uint256 end = startIndex + count; if (end > auctions.length) end = auctions.length; for (uint i = startIndex; i < end; i++) { auctions[i].finalize(); } }

Gas Refund Mechanism

💰 Gas Refund from Storage Zeroing
When you SET a storage slot to ZERO (clearing it): → You receive a gas refund of 4,800 gas Example: myMapping[user] = 0; // If it was non-zero before → 4,800 gas refund This refund is applied at the END of the transaction: Max refund = 1/5 of total gas used (EIP-3529 change in London fork) Before London fork (EIP-3529): Max refund was 1/2 of gas used → "gas tokens" were profitable Gas tokens (like GST2) stored gas when cheap, refunded when expensive EIP-3529 made this unprofitable — gas tokens are now obsolete Practical impact for auditors: Clearing storage slots at end of transaction saves gas DELETE mappingEntry is equivalent to setting to zero type default
✏️ Try It — Calculate Gas Cost for a Function
Exercise: Estimate gas for this function call function updateUser(address user, uint256 newBalance) external { require(msg.sender == admin); // SLOAD (admin) = 2,100 cold emit BalanceUpdated(user, newBalance); // LOG2 = 1,125 + data balances[user] = newBalance; // SSTORE: if new = 20,000 } Rough calculation: SLOAD (admin check) = 2,100 gas Function overhead = ~200 gas (jumps, etc.) LOG2 (event with 2 topics) = 1,125 + 32 bytes * 8 = 1,381 gas SSTORE (new balance slot) = 20,000 gas Base transaction cost = 21,000 gas --- Total estimate: ~44,700 gas Verify in Foundry: forge test --gas-report Or use Remix IDE's gas estimator after compiling.

Common Mistakes Section

⚠️ Mistake #1: Looping Over Unbounded Arrays

Any loop that iterates over an array whose length is controlled by external parties (or grows over time) is a DoS risk. Always use pagination or pull patterns instead of pushing to all recipients.

⚠️ Mistake #2: Using transfer() or send() in Modern Code

transfer() and send() hard-code a 2,300 gas stipend. After EIP-1884, some operations cost more than 2,300 gas (e.g., SLOAD went from 200 to 2,100). This has caused legitimate ETH sends to fail when the recipient is a contract. Use .call{value: amount}("") with proper reentrancy protection instead.

⚠️ Mistake #3: Reading Storage Variables Multiple Times in a Loop

Each SLOAD costs 100-2,100 gas. Reading the same storage variable 100 times in a loop = 10,000-210,000 gas wasted. Cache storage reads in memory variables: uint256 len = myArray.length; before the loop.

Summary / Key Takeaways

ConceptKey FactSecurity Relevance
Gas UnitsFixed cost per EVM opcodeSSTORE 20K makes loops dangerous
Base FeeDynamic, burned, protocol-setBlock stuffing attacks inflate this
Gas LimitMax gas per tx / per blockExceeded limit = permanent DoS
Out of GasAll state reverted, gas still chargedAttackers can cause expensive failures
.call forwardingForwards all remaining gasEnables reentrancy attacks
Unbounded loopsCan exceed block gas limitFunctions become permanently broken
Storage vs MemorySSTORE 20K vs MSTORE 3 gasCache storage in memory in loops