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:
// 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 irrationalGas 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
| Concept | What It Is | Who Sets It | Example Value |
|---|---|---|---|
| Gas Units | The amount of computational work | The EVM (fixed per opcode) | 21,000 units (simple ETH transfer) |
| Base Fee | Minimum price per gas unit (burned) | Protocol (automatically adjusted) | 15 gwei per unit |
| Priority Fee | Tip to validator (they keep this) | You (the sender) | 2 gwei per unit |
| Max Fee | Maximum you'll pay per gas unit | You (the sender) | 30 gwei per unit |
| Total Cost | Units x (baseFee + priorityFee) | Calculated at inclusion time | 21,000 x 17 gwei = 0.000357 ETH |
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 thisEIP-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:
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 attackCommon EVM Opcodes and Their Gas Costs
| Opcode | Operation | Gas Cost | Notes |
|---|---|---|---|
| ADD, SUB | Integer arithmetic | 3 | Very cheap |
| MUL, DIV | Multiplication/division | 5 | Very cheap |
| MLOAD, MSTORE | Read/write memory | 3 base + expansion | Memory expands quadratically |
| SLOAD | Read from storage | 100 warm / 2,100 cold | Very expensive vs memory |
| SSTORE (new slot) | Write non-zero to zero slot | 20,000 | Most expensive common op |
| SSTORE (update) | Change existing non-zero value | 2,900 | Much cheaper than new slot |
| SSTORE (zero out) | Set slot to zero | 2,900 + 4,800 refund | Gives gas refund |
| CALL | External call to contract | 100 base + forwarded gas | Extra 9,000 for value transfer |
| CREATE | Deploy new contract | 32,000 + bytecode cost | Very expensive |
| LOG (event) | Emit an event | 375 + 8 per byte | Cheaper than storage |
| KECCAK256 | Hash 32 bytes | 30 + 6 per word | Common in mapping lookups |
| Base tx cost | Simple ETH transfer | 21,000 flat | Minimum transaction cost |
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
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
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.
// 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
// 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
// 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
// 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
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 defaultExercise: 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
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.
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.
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
| Concept | Key Fact | Security Relevance |
|---|---|---|
| Gas Units | Fixed cost per EVM opcode | SSTORE 20K makes loops dangerous |
| Base Fee | Dynamic, burned, protocol-set | Block stuffing attacks inflate this |
| Gas Limit | Max gas per tx / per block | Exceeded limit = permanent DoS |
| Out of Gas | All state reverted, gas still charged | Attackers can cause expensive failures |
| .call forwarding | Forwards all remaining gas | Enables reentrancy attacks |
| Unbounded loops | Can exceed block gas limit | Functions become permanently broken |
| Storage vs Memory | SSTORE 20K vs MSTORE 3 gas | Cache storage in memory in loops |