T8 · Inside the EVM
Why can the same assignment cost a hundred times more Gas?
- Capabilities practiced
- BuilderProtocol Literacy
- Hands on
- Write three functions with identical behavior but different storage layouts, and measure their gas use.
- AI Lab
- Have AI explain the storage-collision risk in delegatecall code. Build a test yourself that demonstrates the risk.
A Real-World Problem
You write a batch distribution function. It accepts a list of addresses and amounts, updates each balance, then updates the cumulative total.
function airdrop(address[] memory to, uint256[] memory amounts) external {
for (uint256 i = 0; i < to.length; i++) {
balanceOf[to[i]] += amounts[i];
totalDistributed += amounts[i]; // This line
}
}It works on the testnet. On mainnet launch day, operations passes in 200 addresses. The transaction exceeds the block limit and cannot be submitted.
You move the totalDistributed update outside the loop and change the parameters from memory to calldata. The logic stays the same, but the transaction now goes through at a much lower cost.
The same assignment can differ in cost by one or two orders of magnitude. This is not a compiler optimization failure. You are paying for different resources. This chapter asks what, exactly, you are paying for.
Thought Experiment
Imagine the EVM as a machine with only three kinds of space.
First: a permanent filing cabinet shared by the world. Put one page in it, and every node worldwide must keep a copy forever. Every node that joins later must download it too. The price buys “everyone on the network, forever.”
Second: a sheet of scratch paper. Write on it during execution and tear it up after the transaction. No one needs to retain it. It is cheap, but the price of a growing sheet is nonlinear: each expansion costs more than the last.
Third: a letter that has already arrived. The caller sends arguments with the transaction. Those bytes must travel with the transaction and be broadcast across the network anyway. You merely read them, allocating no extra space. It is the cheapest option, and it is read-only.
Now reconsider the opening code. totalDistributed += amounts[i] inside the loop writes to the filing cabinet on every iteration. Move it outside, and it writes once. A memory argument copies the whole letter onto scratch paper before use; calldata reads the letter directly.
The pricing logic is simple: pay according to whose resources you occupy and for how long. Remember that and you do not need to memorize the Gas differences below.
Now add a second thought experiment, which explains the rest of this chapter.
Suppose you have a script (another contract’s code) and perform it in your own house. The script says, “Replace what is in drawer 0 with X.” Whose drawer 0 does it mean?
Yours. The script belongs to someone else, but the house and drawer belong to you. Therefore, what drawer 0 is supposed to contain depends on the script writer’s assumptions, not your actual arrangement. They think drawer 0 contains the administrator’s name; you keep the safe key there. That is how the accident happens.
Your Decision
Return to the batch distribution function. If you can change only one thing, which do you change?
What Happens
All four options move data within the same table:
| Where data lives | How long it lives | Who bears the cost | Relative cost |
|---|---|---|---|
| Persistent storage | Forever | Every node on the network, forever | Highest |
| Event log | Forever in history | Nodes keep history, outside the state tree | Medium, far below storage |
| Execution memory | One execution | The executing node, briefly | Low, but grows superlinearly with size |
| Calldata | One execution, read-only | Bytes that must be broadcast anyway | Lowest |
One conclusion deserves a sticky note on your monitor:
Gas is not a function of lines of code. It is a function of whose resources you occupy and for how long.
Several common questions then answer themselves. Why is reading a state variable much more expensive than reading a local variable? You must visit the filing cabinet. Why does clearing a value give a refund? You freed filing-cabinet space. Why does calling a view function offchain cost nothing? No one has to reach consensus on it. Why are loops over large arrays time bombs? Their cost grows with data, while the block limit stays fixed.
Build a Model
Three Spaces
| Keyword | Location | Writable? | Lifetime | Typical use |
|---|---|---|---|---|
storage | State tree | Yes | Permanent | Balances, configuration, permissions |
memory | Execution memory | Yes | One external call | Intermediate results, arrays that need modification |
calldata | Call data | No | One external call | Read-only arguments, especially arrays and bytes |
A practical rule: default reference-type parameters of external functions to calldata; use memory only when you really need to modify them.
Storage Layout
State variables take 32-byte slots in declaration order. Consecutive small types that fit in one slot are packed together:
contract Layout {
uint128 a; // Low 16 bytes of slot 0
uint128 b; // High 16 bytes of slot 0; shares it with a
uint256 c; // slot 1
address owner; // Low 20 bytes of slot 2
bool paused; // Slot 2, next to owner
mapping(address => uint256) bal; // Slot 3 is reserved, but holds no value itself
}Mappings and dynamic arrays do not store their data in their own slot. Instead, that slot number participates in a hash calculation to locate each element. For a mapping(address => uint256), the value at key k is stored at keccak256(abi.encode(k, slotOfMapping)).
This has two practical uses: you can read anyone’s balance directly from the chain without a contract interface, and you can change any slot directly in a test. The latter is how the Fork tests in T12 can “give yourself money.”
Writes also have three price tiers: zero to nonzero is most expensive (new data every node must save forever); nonzero to another nonzero is much cheaper (no increase in data size); and nonzero to zero gives a partial refund (space is freed). Exact figures change with protocol upgrades, so do not memorize them. The three-tier structure is stable, and it explains why the first operation is often especially expensive.
Anatomy of a Call
An external call arrives as bytes. The first four are the function selector: the first four bytes of the hash of the function-signature string. Arguments follow, with each basic type padded to 32 bytes. Dynamic types (arrays, bytes, strings) put an offset first and append the contents later.
0xa9059cbb ← Selector, 4 bytes
000000000000000000000000abcdef…0001 ← First argument: address, left-padded to 32 bytes
00000000000000000000000000000000000000000000000000000000000f4240 ← Second argument: amountThree things follow:
- A function is identified uniquely by its name plus argument types. Same name, different arguments means entirely different functions at the ABI level.
- A selector has only four bytes, so collisions exist. This becomes a real trap in proxy contracts.
- Calldata size directly costs money. Each additional 32 bytes of arguments adds a broadcast cost for users. On L2, it is often the largest part of the fee.
Delegatecall: Changing Whose Storage It Is
An ordinary call means “go to your house, run your code, change your storage.” delegatecall means “bring your code to my house and run it against my storage, while preserving msg.sender and msg.value.”
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
// The borrowed script
contract Logic {
address public owner; // Logic thinks this is slot 0
function setOwner(address a) external {
owner = a; // Actually writes the caller's slot 0
}
}
// Your own house
contract Proxy {
address public implementation; // slot 0 ← collides with Logic.owner
address public admin; // slot 1
constructor(address impl) {
implementation = impl;
admin = msg.sender;
}
fallback() external payable {
// A real proxy must also forward return data unchanged, using inline assembly; omitted here
(bool ok, ) = implementation.delegatecall(msg.data);
require(ok, "delegatecall failed");
}
}If anyone calls Proxy using setOwner(0x…), the effect is to change implementation to the attacker’s address. On the next call, the whole proxy runs the attacker’s code.
This is the definition of delegatecall, not a bug in it. Code and storage have been separated, while the meaning of storage lives in the code. If the two sides disagree, the result is disastrous.
Three hard rules for proxies follow. T28 expands on each:
- Put the proxy’s own variables in slots that cannot collide with the logic contract. In practice, use fixed pseudorandom slots rather than declarations starting at slot 0.
- When upgrading logic, storage layout in the new version may only be appended at the end. Do not insert, change types, or reorder.
- A logic contract must never contain a callable self-destruction path or an initializer anyone can use.
What It Is Called
The unit used to meter payment for each operation. It measures “how much burden this execution places on the network,” not “how many lines of code you wrote.”
The final cost equals Gas used multiplied by its price. Your code determines the amount used; the market determines the price. You can optimize only the former.
Data written to the state tree and retained permanently by every network node. It is the most expensive class of operation.
Three price tiers: new writes cost most, overwrites less, and clearing gives a refund. Most contract cost optimization is about reducing storage accesses.
Temporary space for one external call, released when execution ends.
It is cheap, but its expansion cost grows faster than linearly. Copying a large array into memory may cost much more than expected.
Read-only input bytes sent with a transaction.
You cannot change them. That is why reading them needs no new allocation and is the cheapest way to read arguments. On L2, calldata is also a major component of fees.
The rules for encoding functions and arguments as bytes: a four-byte selector followed by 32-byte-aligned arguments.
It is the common language between contracts, and between offchain systems and contracts. Understanding it lets you interpret a transaction’s input field.
Runs another contract’s code against the current contract’s storage while preserving the original caller and value context.
It is the foundation of upgradeable contracts and a source of storage collisions and initializer takeovers. Before using it, ask: do these two pieces of code agree on what slot 0 contains?
Hands-on
This lab produces a table of your own measurements, not a memorized conclusion. The course gives no exact figures because protocol upgrades change them. The orders-of-magnitude differences remain, and you should see them firsthand.
Write these three versions. Each does the same thing: sum an array and add it to a state variable.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract GasLab {
uint256 public total;
// A: write persistent storage on each loop iteration
function sumA(uint256[] memory xs) external {
for (uint256 i = 0; i < xs.length; i++) {
total += xs[i];
}
}
// B: use a local variable in the loop; write once at the end
function sumB(uint256[] memory xs) external {
uint256 acc = total;
for (uint256 i = 0; i < xs.length; i++) {
acc += xs[i];
}
total = acc;
}
// C: read directly from calldata instead of copying arguments to memory
function sumC(uint256[] calldata xs) external {
uint256 acc = total;
for (uint256 i = 0; i < xs.length; i++) {
acc += xs[i];
}
total = acc;
}
}Call each version with the same input and record actual Gas used. Use a test framework’s Gas report or read gasUsed from the transaction receipt.
Run each with arrays of length 5, 50, and 200, creating a 3-by-3 table. Focus on the slopes of the three curves, not just their absolute values.
Compare the first write. Run once with total starting at 0 and once starting at a nonzero value. Compare the Gas used by the same function.
That is the three-tier pricing discussed above. Many people assume their measurement is wrong the first time they see it.
Verify storage layout. Add mapping(address => uint256) public bal;, write a value, then read it directly with eth_getStorageAt. You must calculate keccak256(abi.encode(address, slotNumber)) yourself.
Get it right and you have a powerful capability: read any contract’s internal state, with or without a provided interface.
Trigger a storage collision yourself. Deploy the Proxy and Logic above locally, call setOwner through the proxy, then read implementation.
Once you see it change to the address you passed in, the risk of delegatecall is no longer a fact you must memorize.
Keep everything on a local node or testnet; no real assets are needed.
AI Lab
Give the model this chapter’s Proxy and Logic unchanged. Use three steps.
Step one:
These two contracts are combined with delegatecall. For each storage slot, list what Proxy
thinks it contains and what Logic thinks it contains. Identify every mismatch.
Step two:
Using those mismatches, construct a concrete attack: which transactions does the attacker
send, in what order, and what do they gain? Describe actual function calls, not abstractions.
Step three:
Write a test that must fail against the current code and pass after it is fixed.The model usually does well on step one because it is essentially an alignment table.
Step two separates stronger reasoning from plausible invention: the model can easily propose an attack that sounds reasonable but cannot actually execute, perhaps calling a function that does not exist or overlooking when the fallback is triggered.
Step three is the point of this lab. A test that fails on the bad code is the only way here to establish the risk as real. If the model cannot write it, or its proposed test passes on the bad code, at least one earlier step was invented.
Then ask it for a fix and rerun the step-three test. A red test turning green shows the fix works; compilation alone proves nothing.
Verify these points yourself after using AI
- You read the slot numbers it gave you with eth_getStorageAt and confirmed each one
- For every risk it identified, you wrote a test that demonstrates it; an unprovable risk may be invented
- Did it cite a nonexistent library, contract, or standard number? Check every citation yourself
- Did it say adding a library makes this safe without explaining what that library does?
- After applying its fix, the test that previously demonstrated the risk no longer demonstrates it
- Compilation does not prove a fix; every fix needs a corresponding test
Real-World Case
Many wallets used the same library contract through delegatecall. The library had a function to set its owner, but did not limit it to one call or to calls through a proxy.
Someone called the library itself directly, became its owner, then destroyed it. Every dependent wallet became an empty shell at once: the code was gone, delegatecall targeted a nonexistent address, and funds became permanently inaccessible.
T7 taught that anyone may call an external entry point. Here is the other half: a logic contract is itself a directly callable address. Do not assume it will only be called through a proxy.
To keep code tidy, a protocol adds a new variable before existing ones in the next version of its logic contract.
After upgrading, every storage position is misinterpreted: balances read as configuration, and configuration reads as addresses. Funds are not stolen, but the ledger is scrambled and operations stop for repairs.
The rule is strict: append at the end only; do not insert, change types, reorder, or delete. A storage-layout comparison script in CI is more reliable than code review alone.
A contract loops through all token holders when distributing dividends. With a few dozen addresses at launch, it is fast.
After holders number in the thousands, the function’s Gas exceeds the limit of a block and can never execute. Anyone can push it over the threshold deliberately by creating many addresses with tiny holdings.
The general fix is to change “push” to “pull”: rather than the contract paying everyone one by one, each person claims their own payment and bears their own transaction cost.
On L2 networks that post transaction data back to mainnet, execution itself can be a small part of the fee. Calldata often dominates.
Compressing a function’s long struct arguments into compact encoding can save substantially, while the same change on mainnet may yield much less.
This confirms the rule: optimize for whose resources you occupy, not an abstract idea of “code.” Change networks, and the resource bottleneck changes with it.
Change One Variable
If the fields are always read and written together, you avoid a storage access and save noticeably.
If they are usually accessed separately, the compiler has to read the whole slot and use shifts and masks each time, which may cost more.
Packing depends on access patterns, not type size. Examine call paths before the change, then measure with the lab method afterward.
When functions do not modify their arguments, this is nearly side-effect-free, with larger arrays giving greater savings.
But the requirement spreads: if an argument is passed to an internal function expecting memory, the compiler copies it there. You have moved the cost rather than removed it. Real optimization keeps the whole call path read-only.
It does nothing on the normal path, because whether proxy or logic code is destroyed depends on how the call is made.
But if anyone can call the logic contract directly, and it has an unprotected initializer, the 2017 incident above can happen again.
The conclusion: the logic contract’s constructor should mark that contract as “already initialized,” preventing anyone from initializing it afterward.
Exact numbers change, but the model remains: persistence costs most, memory less, and read-only input least. The cost reflects who bears the long-term burden.
That is why this chapter gives you no exact figures. Numbers expire; the table you measured in the lab does not, because you can measure it again whenever needed.
Takeaway Questions
Why do we need a blockchain? Gas pricing is the most honest answer: every persistent write requires every node in the world to keep a copy forever. The expense is the bill for independent verification across the network, not a design defect. Understand that and you will stop asking why it cannot simply be cheaper.
Who pays? In this chapter, the caller. Any design with a loop length controlled by user input sends an uncertain bill to users. When that bill exceeds the block limit, the issue is no longer just price but whether the feature can be used at all.
Who bears the risk? Delegatecall moves risk to a place that is easy to miss: users interact with a proxy address, but replaceable code elsewhere determines behavior. When studying a protocol, after reading the contract ask whether this address is a proxy and who can replace its logic.
Chapter Check
Every persistent write requires the network’s nodes to update and retain data forever. A local-variable addition happens only in temporary execution space.
Write N times in a loop and pay for N persistent operations; write once afterward and pay once. The difference grows with N. That is why the opening transaction changed from “over the limit” to “submit successfully.”
Default to calldata. Use memory only when you need to modify an argument.
calldata consists of read-only bytes that arrived with the transaction, so reading it needs no extra allocation. memory first copies the entire argument. Larger arrays make that copy more expensive.
Remember that calldata here applies to externally visible function arguments. Passing data through internal functions may cause a copy.
The logic contract also declares variables from slot 0. The two contracts may assign different meanings to the same slot. Logic thinks it is writing its first variable, but it actually writes the proxy’s first variable.
In practice, put the proxy’s own variables in slots derived from hashes of fixed strings, far from slots used by ordinary variable declarations.
Three clues:
- Read the conventional fixed slots and see whether they contain a valid contract address.
- Inspect code length; a plain proxy has very short bytecode.
- Look for implementation-change events in transaction history.
Block explorers often mark proxies and provide a link to the implementation, but that is a heuristic guess and can miss nonstandard proxies. For decisions involving funds, inspect the slots yourself.
There is no standard answer. Check:
- Does it access persistent storage inside a loop? That is often the biggest saving.
- Are array and bytes arguments of external functions still in
memory? - Do small fields always read and write together yet occupy separate slots?
- Is there data stored only for convenient queries that no onchain logic needs? It may belong in events.
- Is any loop length set by users? That is an availability risk, not just an optimization issue.
Predict first, then measure. The farther apart they are, the less clear your cost model is. Learning that is worth more than the Gas saved.
Key idea
Storage is expensive, memory is cheaper, and calldata is cheapest; delegatecall changes whose storage is being written.