Week 02 — Ethereum & EVM Deep Dive
“The EVM is a 256-bit stack machine that costs money to run. Every audit finding you will ever write traces back, eventually, to a precise opcode-and-gas-level claim about what happens during execution. You don’t audit Solidity. You audit what Solidity compiles to, what the EVM does with it, and what the protocol assumes about that behavior. The closer your mental model is to the bytecode, the deeper your bugs.”
Tags: web3-security foundations evm opcodes gas storage-layout transactions eip-1559 eip-4844 create2
Learner: Completed Tuan-01-Web3-Blockchain-Crypto-Fundamentals; can use cast for basic crypto primitives.
Time: 7 days (5–6h/day; the densest of the foundations weeks)
Related: Tuan-03-Solidity-Foundry-Workflow · Tuan-04-Security-Foundations-CEI-AC · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-12-Wallet-AA-Key-Management
1. Context & Why
1.1 What this week is and isn’t
This week is not a Solidity tutorial. It is the auditor’s working model of the execution layer: the account state, the transaction envelope, the EVM’s execution semantics, the opcodes that move value and execute logic, the gas accounting that bounds them, and the storage layout rules that decide where state lives.
By Friday, when you read a Solidity function, you should be able to answer instantly:
- Which storage slot does each state variable land in? Which slots get touched on each call?
- What’s the gas footprint at the opcode level — cold vs warm access, memory expansion, CALL gas forwarding?
- What does the transaction look like on the wire? Type-2 EIP-1559? Type-3 blob? Type-4 set-code (EIP-7702)?
- Which opcodes does this function path execute, in what order, with what side effects on storage, memory, logs?
- Where are the seams between the contract and the EVM that an attacker can lean on?
If you cannot answer these, you are reading source code instead of auditing. Source code is the developer’s view. Bytecode + state transitions + gas + storage layout is the auditor’s view.
1.2 Why this matters to auditors
Three concrete bug classes that only a strong execution-layer model surfaces:
| Bug class | Manifests as | Requires understanding |
|---|---|---|
| Storage collision under proxy | Upgrade overwrites unrelated state | Slot derivation rules for mappings, dynamic arrays, structs |
Read after delegatecall to unknown code | Caller storage rewritten by callee | Storage is bound to address(this), not the running code |
| Gas-grief / 63-64 rule | Outer call appears to succeed but post-call ops revert | EVM CALL gas forwarding, sub-call returns |
| Selector collision in fallback router | Wrong function dispatched | Function selector = keccak256(sig)[:4] is only 4 bytes |
| CREATE2 address squat | Attacker deploys to expected address with different init code | CREATE2 address = keccak256(0xff ‖ deployer ‖ salt ‖ keccak(init))[12:] |
tx.origin reasoning under AA / EIP-7702 | Access control passes for wrong reason | EOAs can now have code; msg.sender.code.length is no longer a reliable EOA check |
Every one of these has shipped to mainnet. Every one was reviewed by a Solidity developer who had never internalized the precise semantics. Don’t be that auditor.
1.3 Learning goals for the week
By the end, you can:
- Distinguish EOA and contract accounts at the state-trie level (
nonce,balance,storageRoot,codeHash) and explain how each is set. - Walk through a transaction’s full lifecycle: sign → mempool → block → execution → receipt → log indexing — and name each artifact an auditor can pull.
- Identify which of the five live transaction types (
0x00,0x01,0x02,0x03,0x04) a raw tx is, by reading the first byte. - Compute, by hand, the gas paid for a transaction under EIP-1559 (basefee burned + priority fee to proposer).
- State, without notes, the gas cost of: cold SLOAD, warm SLOAD, cold CALL, warm CALL, zero→nonzero SSTORE, nonzero→nonzero SSTORE.
- Compute the storage slot of
m[k1][k2]in amapping(uint => mapping(address => uint))declared at slotp. - Read the EIP-1967 implementation slot of any proxy contract on mainnet with one
cast storagecall. - Recognize all five “address-controlling” opcodes (
CREATE,CREATE2,CALL,STATICCALL,DELEGATECALL) and state what each can and cannot do to storage / value. - Decode the calldata of an arbitrary mainnet transaction using
cast 4byte-decode. - Explain how EIP-7702 changes the
msg.sender.code.length == 0heuristic and what audit checks must be updated.
1.4 Primary references
| Source | URL | Status / why |
|---|---|---|
| Ethereum.org — Accounts | https://ethereum.org/en/developers/docs/accounts/ | Current; canonical account model |
| Ethereum.org — Transactions | https://ethereum.org/en/developers/docs/transactions/ | Current; lifecycle and tx types |
| Ethereum.org — EVM | https://ethereum.org/en/developers/docs/evm/ | Current; execution model overview |
| Ethereum.org — Gas | https://ethereum.org/en/developers/docs/gas/ | Current; post-1559 fee mechanics |
| Ethereum Yellow Paper | https://ethereum.github.io/yellowpaper/paper.pdf | Formal EVM semantics |
| Solidity Docs — Layout in Storage | https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html | Current; storage slot derivation rules |
| Solidity Docs — ABI Specification | https://docs.soliditylang.org/en/latest/abi-spec.html | Current; selector + encoding rules |
| evm.codes (Smlxl, formerly Dune) | https://www.evm.codes/ | Current; interactive opcode/gas reference |
| EIP-1559 — Fee market | https://eips.ethereum.org/EIPS/eip-1559 | Final (London, Aug 2021) |
| EIP-2930 — Access lists | https://eips.ethereum.org/EIPS/eip-2930 | Final (Berlin, Apr 2021) |
| EIP-2929 — Gas for state access | https://eips.ethereum.org/EIPS/eip-2929 | Final (Berlin) |
| EIP-3529 — Refund cap | https://eips.ethereum.org/EIPS/eip-3529 | Final (London) |
| EIP-3860 — Initcode size limit | https://eips.ethereum.org/EIPS/eip-3860 | Final (Shanghai) |
| EIP-4399 — PREVRANDAO | https://eips.ethereum.org/EIPS/eip-4399 | Final (Paris / The Merge) |
| EIP-4844 — Blob transactions | https://eips.ethereum.org/EIPS/eip-4844 | Final (Cancun, Mar 2024) |
| EIP-1153 — Transient storage | https://eips.ethereum.org/EIPS/eip-1153 | Final (Cancun) |
| EIP-6780 — Reduced SELFDESTRUCT | https://eips.ethereum.org/EIPS/eip-6780 | Final (Cancun) |
| EIP-1967 — Proxy storage slots | https://eips.ethereum.org/EIPS/eip-1967 | Final (ERC) |
| EIP-1014 — CREATE2 | https://eips.ethereum.org/EIPS/eip-1014 | Final (Constantinople) |
| EIP-2718 — Typed transaction envelope | https://eips.ethereum.org/EIPS/eip-2718 | Final (Berlin) |
| EIP-7702 — Set-code transaction | https://eips.ethereum.org/EIPS/eip-7702 | Final (Pectra, May 7, 2025) |
| ERC-4337 — Account Abstraction | https://eips.ethereum.org/EIPS/eip-4337 | Last Call (May 2026 deadline) [verify] — deployed and in production via the EntryPoint contract since 2023 |
| Foundry Book | https://book.getfoundry.sh/ | Current; cast, forge, anvil reference |
2. The Ethereum Account Model
2.1 Two account types, one address space
Every Ethereum address (a 20-byte / 40-hex-character identifier) refers to an entry in the world state trie — a Merkle-Patricia trie whose leaves are accounts. Each account, regardless of type, stores exactly four fields:
| Field | Type | Description |
|---|---|---|
nonce | uint64 | For EOAs: number of transactions sent. For contracts: number of CREATEs from this address. Critical for replay / address derivation. |
balance | uint256 (wei) | Ether held. |
storageRoot | bytes32 | Root of this account’s storage trie. Empty (keccak256("")) for EOAs that never gained code (pre-7702). |
codeHash | bytes32 | keccak256(code). Equals keccak256("") (i.e., 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470) for EOAs without code. |
Two account “types” are conventionally distinguished, but they are not separate data structures — they are the same record with different codeHash values:
flowchart LR Addr[20-byte Address] --> Lookup[State Trie Lookup] Lookup --> Acct[Account Record] Acct --> Nonce[nonce] Acct --> Bal[balance] Acct --> SRoot[storageRoot] Acct --> CHash[codeHash] CHash -->|= empty hash| EOA[EOA<br>controlled by private key] CHash -->|!= empty hash| CA[Contract Account<br>controlled by code]
Post-Pectra (EIP-7702), this binary partition becomes a spectrum: an EOA can authorize a delegation designator (0xef0100 ‖ delegate_address) that lives in its codeHash. The EOA is still controlled by its private key for authorization, but on incoming CALL, the EVM executes the delegate’s code in the EOA’s context. The auditor consequence: address.code.length == 0 no longer reliably means “EOA”; see §11.
2.2 What “controlled by” means
| EOA | Contract account |
|---|---|
| Controlled by a secp256k1 private key | Controlled by deployed bytecode |
| Can initiate a transaction (sign the tx envelope) | Cannot initiate; only reacts to a CALL |
nonce increments on each sent tx (pre-7702) | nonce increments on each successful CREATE / CREATE2 from this account |
codeHash = keccak256("") (pre-7702) | codeHash = keccak256(deployed_code) |
storageRoot = keccak256("") (pre-7702) | Storage trie holds the contract’s state slots |
Audit reflex: when a contract function checks “is the caller a contract?” via msg.sender.code.length > 0, it is asking a state-trie question. The answer:
- Returns
0for an EOA without delegation. - Returns
0for a contract during its constructor (the code is not yet committed to state). This is the classic “construct-and-call-back” bypass. - Returns nonzero (specifically 23 bytes:
0xef0100+ 20-byte address) for a 7702-delegated EOA.
This single check has been the root of bypasses for years. Always pair it with what the contract is actually trying to enforce — and prefer tx.origin == msg.sender only with the explicit caveat that it breaks under AA / 7702.
2.3 Address generation — three paths
flowchart TD EOA[EOA Address] EOA --> EOA_form["keccak256(secp256k1_pubkey)[12:]"] CREATE[CREATE address] CREATE --> CREATE_form["keccak256(RLP(sender, nonce))[12:]"] CREATE2[CREATE2 address] CREATE2 --> CREATE2_form["keccak256(0xff ‖ deployer ‖ salt ‖ keccak256(init_code))[12:]"] P7702[EIP-7702 Delegation] P7702 --> Reuse["Reuses the EOA's existing address"]
| Path | Inputs | Deterministic from outside? |
|---|---|---|
| EOA | private key → public key | Yes, given the public key |
| CREATE | (sender, sender.nonce) | Yes, but moves with sender’s nonce |
| CREATE2 | (deployer, salt, init_code) | Yes, fully — independent of nonce |
| EIP-7702 delegation | None — reuses the EOA address | n/a |
Audit signal: CREATE2 with attacker-influenceable salt or init_code is the “address squatting” primitive. If two paths can produce the same address with different code, downstream contracts that trust “code at known address” can be fooled. See §9.3.
3. Transaction Anatomy & Lifecycle
3.1 The auditor’s view of a transaction
From the user’s wallet to a finalized state change:
flowchart LR W[Wallet] -->|RLP-encoded<br>+ signature| RPC[RPC<br>node] RPC -->|gossiped| MP[Mempool /<br>private relay] MP -->|picked by| BP[Block Proposer<br>or MEV builder] BP -->|included in block| EXEC[Block Execution<br>state[n] → state[n+1]] EXEC -->|emits| RCPT[Receipt<br>+ logs] RCPT -->|indexed| IDX[Indexers /<br>Etherscan / Subgraphs] RCPT -->|finalizes after<br>~2 epochs| F[finalized] style MP fill:#fff2cc style BP fill:#fff2cc
Each arrow is an artifact an auditor can pull during incident reconstruction:
| Artifact | Source | What it tells you |
|---|---|---|
| Raw signed tx (hex) | Wallet logs, mempool archives | Exact bytes the user signed |
| Mempool snapshot | Block-builder / mempool.dumpster | Whether the tx was public or private (Flashbots Protect, MEV-Share) |
| Block-inclusion data | Beacon API, Etherscan | Proposer, block number, slot, gas used |
| Receipt | eth_getTransactionReceipt | Success bool, gas used, logs, cumulativeGasUsed |
| Trace | debug_traceTransaction / Tenderly | Opcode-level execution |
| State diff | Tenderly, Phalcon | Slot-level changes |
When auditing a live exploit, the receipt + the trace are the gold sources. The receipt confirms whether the tx succeeded; the trace shows you exactly which opcodes fired, what the state changes were, and where revert occurred (if any).
3.2 Transaction types in production
EIP-2718 (Final, Berlin) introduced the typed-transaction envelope. The first byte of the serialized payload identifies the type:
| Type byte | Name | Introduced by | Key feature |
|---|---|---|---|
0x00 (implicit; legacy RLP starts 0xc0–0xff) | Legacy | Pre-2021 | (nonce, gasPrice, gasLimit, to, value, data, v, r, s) — pre-1559 single-price model |
0x01 | Access list | EIP-2930 (Berlin) | Adds accessList for pre-warmed addresses/slots |
0x02 | Dynamic fee | EIP-1559 (London) | maxFeePerGas, maxPriorityFeePerGas; basefee burned |
0x03 | Blob-carrying | EIP-4844 (Cancun) | Carries blob versioned hashes; pays separate blob-gas fee |
0x04 | Set-code | EIP-7702 (Pectra, 2025) | Carries authorization_list; lets EOA delegate to a contract |
A schematic Type-2 (EIP-1559) transaction payload (RLP-encoded after the 0x02 prefix byte):
[chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit,
to, value, data, accessList,
signatureYParity, signatureR, signatureS]
Reading these fields off the wire is the basis of every wallet UX, every block explorer, every MEV strategy. Auditors should be able to identify a transaction’s type by sight when reading mempool dumps.
3.3 RLP — just enough for auditors
RLP (Recursive Length Prefix) is Ethereum’s “everything that gets hashed or transmitted” encoding. You do not need to implement it. You do need three facts:
- RLP encodes either a single byte string or a list of items. Numbers are encoded as their big-endian byte string with no leading zeros.
keccak256(RLP(...))is the canonical hash function for transaction hashes, block hashes, and CREATE address derivation. When someone says “tx hash,” they meankeccak256(serialization)where serialization is RLP for legacy types andtype_byte ‖ RLP(payload)for typed transactions.- Empty string is
0x80. Empty list is0xc0. Single byte< 0x80encodes as itself. Lists ≤ 55 bytes get0xc0 + length; longer lists use0xf7 + length_of_lengthfollowed bylength.
Why an auditor cares: when a contract validates a Merkle-Patricia proof of a storage slot (e.g., for an L1-L2 message proof), the proof leaves are RLP-encoded account/storage records. A buggy decoder is the bug class.
3.4 Receipts and logs
Every executed transaction produces a receipt:
{
status: 0 | 1, // 1 = success, 0 = revert (but gas was still consumed)
cumulativeGasUsed, // gas used by this and prior txs in the block
gasUsed, // gas used by this tx alone
effectiveGasPrice, // basefee + min(priorityFee, maxFee - basefee)
logs: [{address, topics[], data}, ...],
logsBloom, // 2048-bit bloom for fast event filtering
contractAddress | null // set if this tx created a contract
}
Critical fact: a successful tx with status: 1 and no logs is a function that completed without emitting anything. A revert returns status: 0, all state changes are reverted, but the gas is still consumed and the transaction is included in the block. An attacker can therefore burn user gas without changing state; gas-grief vectors exploit this.
Logs (LOG0–LOG4 opcodes) are the only way contracts communicate to off-chain systems. Each log has:
address: the emitting contract.topics[]: up to 4 indexed fields (each 32 bytes). Topic[0] is conventionallykeccak256(event_signature)for standard events; for anonymous events the developer controls topic[0].data: ABI-encoded non-indexed fields, unbounded length.
Audit relevance: indexers (subgraphs, dApp backends, Etherscan) decode events using the ABI. If a contract emits a different-shaped event than the ABI declares (e.g., manual assembly { log0(...) }), the indexer silently mis-parses. Bug class: relying on event data for off-chain accounting without a fallback that reads on-chain state.
4. Gas — Post-1559 Mechanics
4.1 The two-component fee model
Pre-EIP-1559, a tx had one gasPrice. Bidding wars dominated UX.
Post-1559 (Final, London, August 2021):
fee_per_gas_paid_by_user = basefee + min(priorityFee, maxFee - basefee)
fee_per_gas_burned = basefee
fee_per_gas_to_proposer = min(priorityFee, maxFee - basefee)
Where:
basefeeis set by the protocol per block. It adjusts up/down by up to 12.5% per block based on whether the previous block was above/below 50% of the gas-limit target (per the EIP-1559 formula). It is burned (sent to the zero address).maxPriorityFeePerGas(a.k.a. tip) is the user’s offer to the proposer.maxFeePerGasis the user’s absolute ceiling.gasLimitis the maximum gas units the tx is allowed to consume; if exceeded, the tx reverts with out-of-gas, but the fullgasLimit * effectiveGasPriceis still paid.
flowchart LR User --> Pay[Pays effectiveGasPrice × gasUsed] Pay --> Burn[basefee × gasUsed<br>burned] Pay --> Tip[priority × gasUsed<br>to proposer] style Burn fill:#ffcccc style Tip fill:#90ee90
Audit relevance:
- Contracts that pay back gas to users (refund flows) often miscompute the actual cost because they read
tx.gaspricewhich under 1559 equalseffectiveGasPrice(= basefee + priority), not the user’s bid. - MEV-Boost / PBS economic models depend on this split; deep-MEV audits trace where the value flows.
4.2 Cold vs warm access (EIP-2929)
To raise costs on state-bloat attacks (the 2016 Shanghai DoS), EIP-2929 (Final, Berlin) made the first access to any address or storage slot expensive, with subsequent accesses cheap. The two transaction-wide sets are:
accessed_addresses: starts with sender, recipient, and all precompiles. Adding cost: 2,600 gas (cold) → 100 (warm).accessed_storage_keys: starts empty. Adding cost: 2,100 gas (cold) → 100 (warm).
| Opcode | Cold cost | Warm cost |
|---|---|---|
| SLOAD | 2,100 | 100 |
| CALL / STATICCALL / DELEGATECALL / CALLCODE | 2,600 | 100 |
| BALANCE / EXTCODESIZE / EXTCODEHASH / EXTCODECOPY | 2,600 | 100 |
| SELFDESTRUCT (target) | 2,600 | 100 |
(Plus per-opcode base costs; numbers above are the access portion. See evm.codes for the full breakdown.)
4.3 Access lists (EIP-2930)
A Type-1 or Type-2 transaction can pre-declare addresses/slots in an accessList. The transaction pays up-front:
- 2,400 gas per pre-declared address.
- 1,900 gas per pre-declared storage key.
In exchange, those addresses/slots are treated as warm from the start of execution.
Audit angle: useful for predictable cross-contract flows (lending liquidations, oracle-reads). Almost no security implications directly; relevance is mostly that you can identify which slots a tx pre-warmed by reading the access list field — a signal that the sender knew the storage layout.
4.4 SSTORE — the expensive opcode
SSTORE pricing is among the most baroque parts of the protocol. The full table (combining EIP-2200 / EIP-2929 / EIP-3529):
| Scenario | Gas cost | Refund |
|---|---|---|
| Zero → nonzero (cold) | 22,100 | 0 |
| Zero → nonzero (warm) | 20,000 | 0 |
| Nonzero → different nonzero (cold, dirty) | 5,000 | 0 |
| Nonzero → different nonzero (warm) | 2,900 | 0 |
| Nonzero → zero | 5,000 (cold) / 2,900 (warm) | +4,800 |
| No-op (current == new) | 2,200 (cold) / 100 (warm) | 0 |
Refund cap: per EIP-3529 (Final, London), refunds cannot exceed gas_used / 5. The 24,000-gas SELFDESTRUCT refund was removed by EIP-3529 entirely. The cap and the removal kill the “GasToken” exploit class.
Audit implication for reentrancy guards: a typical _status = ENTERED; ...; _status = NOT_ENTERED; pattern is two SSTOREs per protected call. Pre-Cancun, the second SSTORE went nonzero → nonzero (or → zero), each costing ~5,000 gas; the OZ guard used a one-bit trick (_NOT_ENTERED = 1, _ENTERED = 2) to avoid the 20,000-gas zero→nonzero cost. Post-Cancun, EIP-1153 transient storage (TLOAD/TSTORE at 100 gas) makes the guard ~99% cheaper — see §5.4.
4.5 Memory expansion — the quadratic cost
EVM memory is byte-addressable but charged per 32-byte word. Active memory (call it a words) costs:
memory_cost(a) = 3 · a + a² / 512
The marginal cost of growing memory grows linearly in a. At 1 KB of memory used (32 words), expansion is cheap; at 100 KB (3125 words), the quadratic term dominates. An attacker who can force a contract to write to high memory offsets (e.g., via decoding an attacker-supplied byte array) can grief gas without producing useful work.
Audit signal: abi.decode of attacker-supplied dynamic types without a size bound. Always bound input sizes when decoding untrusted data.
4.6 Blob gas (EIP-4844)
EIP-4844 (Final, Cancun, March 2024) introduced a second, independent gas market for blob data:
- Blob size: 4,096 field elements × 32 bytes = 131,072 bytes per blob.
- Blob gas per blob: 131,072 units (2^17).
- Target blobs per block: 3 (as of Cancun); max: 6. (Glamsterdam / future upgrades may change these. [verify])
base_fee_per_blob_gas: adjusts per-block via a “fake exponential” formula similar to EIP-1559’s basefee but smoother.- New opcode:
BLOBHASH(0x49), returns the versioned hash of the i-th blob attached to the tx. 3 gas. - New precompile at address
0x0a: KZG point-evaluation verifier (50,000 gas). - Blobs are stored on the consensus layer and pruned after ~18 days; they are not accessible from the EVM (only their KZG-commitment hashes are).
Audit relevance:
- L2 rollup contracts that anchor batches on L1 used to write the batch data to calldata (expensive). Post-4844, they post the batch as a blob and only the versioned hash hits the EVM. Don’t audit an L2 contract assuming the batch bytes are on-chain — they are only available for ~18 days, after which the rollup must rely on its own DA or restakers.
- The
point_evaluation_precompile(0x0a) is used in fraud-proof / validity-proof verification. If an L2 calls it, audit the inputs carefully — the precompile semantics are subtle.
5. The EVM Execution Model
5.1 The four memory regions
The EVM exposes four distinct regions, each with different cost and persistence:
flowchart TB subgraph Persistent[Per-account, persistent across txs] STO[Storage<br>key 32B → value 32B<br>SLOAD 2100/100 · SSTORE complex] end subgraph Per-tx[Per-transaction] TSTO[Transient Storage<br>key 32B → value 32B<br>TLOAD/TSTORE 100 each] end subgraph Per-call[Per-call frame] MEM[Memory<br>byte-addressable<br>quadratic expansion cost] STK[Stack<br>1024 deep · 256-bit words<br>most ops 3 gas] end subgraph Per-call-ro[Per-call, read-only] CD[Calldata<br>byte-addressable<br>CALLDATALOAD 3 + 16/4 per byte] end
| Region | Width | Lifetime | Set by | Read | Write |
|---|---|---|---|---|---|
| Stack | 256-bit | call frame | PUSH | DUP / SWAP / POP | (implicit via ops) |
| Memory | byte | call frame | MSTORE / MSTORE8 / CALLDATACOPY etc. | MLOAD | MSTORE |
| Calldata | byte | call frame (read-only) | caller | CALLDATALOAD / CALLDATACOPY | n/a |
| Storage | 32B → 32B | persistent | contract | SLOAD | SSTORE |
| Transient storage (EIP-1153) | 32B → 32B | transaction | contract | TLOAD | TSTORE |
5.2 The stack (1024 deep, 256-bit words)
- 256-bit native word size. Everything fits in a 32-byte slot. The width is chosen so that
keccak256outputs, secp256k1 field elements, and uint256 arithmetic all “just fit.” - 1024-element depth limit. Going deeper throws
Stack overflow; an empty pop throwsStack underflow. Both halt the call frame. - Most stack ops cost 3 gas (
PUSH,DUP,SWAP,ADD,SUB,LT,GT,EQ,AND,OR,XOR,NOT,BYTE,SHL,SHR,SAR). Division, modulo, signed mod are 5. Multiplication is 5; exponentiation is10 + 50·byte_size(exp).
The stack-depth limit historically enabled the “shallow attack” pattern: an attacker calls a contract deep enough that the recursive sub-call hits depth 1024, and the checked call inside reverts while the unchecked path continues. Modern Solidity reverts on call failure by default, but raw assembly call patterns can still be vulnerable.
5.3 Memory: byte-addressable, paid by word, quadratic
Memory is erased between calls. It is allocated lazily — only the highest-touched byte determines the cost. Writes:
MSTORE(offset, value): writes 32 bytes atoffset. Cost: 3 gas + memory expansion if needed.MSTORE8(offset, value): writes 1 byte. Cost: 3 + expansion.MLOAD(offset): reads 32 bytes fromoffset. Cost: 3 + expansion.
Solidity uses memory for:
- Function arguments and return values that aren’t in calldata.
- Dynamic-type temporaries (
memory string,bytes memory,uint256[] memory). - The “free memory pointer” at slot
0x40(where the next allocation starts) and scratch space at0x00–0x3f.
Audit reflex: assembly blocks that touch 0x00–0x3f and forget to update 0x40 produce subtle bugs. The cheatsheet for safe inline assembly is in Tuan-03-Solidity-Foundry-Workflow.
5.4 Transient storage (EIP-1153, Cancun) — auditor’s view
TSTORE / TLOAD work like storage but are wiped at end-of-transaction. Both cost 100 gas.
Use cases (and audit implications):
| Use case | Pattern | Audit angle |
|---|---|---|
| Reentrancy guard | tstore(LOCK, 1); ... ; tstore(LOCK, 0) | Drops guard from ~5,000 gas to ~200 gas. But: cheap guards encourage developers to put guards on more functions, including cross-function flows that legitimately need to reenter — verify the design intent. |
| Cross-function locks within one tx | Set transient flag in function A; check in function B | Be sure flag is cleared on revert paths; revert-during-call semantics for transient storage match persistent storage (see EIP-1153). |
| Uniswap V4 hook coordination | Pool manager uses transient storage for callback state | Audit assumption: only one swap is in flight per tx. If the design allows nested swaps, the transient state is shared. |
| ”Sticky” callbacks | Approve-and-call patterns where the spender reads transient permission | Watch for callbacks that survive a sub-call boundary but get reset by a revert. |
Caution — TSTORE low-gas reentrancy: ChainSecurity flagged in 2024 that transient-storage reentrancy guards are so cheap that a malicious callee burning 2300 gas (the old transfer() heuristic) could no longer disable them, but new patterns of intentional re-entry through cheap locks introduce subtle bugs. Always read the protocol’s stated re-entry policy alongside the lock implementation.
5.5 Storage: keyed 32 bytes → 32 bytes
Persistent contract storage is a key-value map: slot (32 bytes) → value (32 bytes). The trie itself is invisible to the EVM; the EVM only sees the K-V interface via SLOAD/SSTORE.
How Solidity decides what goes where is §6 below — it’s the audit-critical part of the model.
5.6 Calldata: read-only, cheap
Calldata is the byte string passed into a call. Read via:
CALLDATALOAD(offset): 32 bytes fromoffset. 3 gas.CALLDATASIZE(): byte length. 2 gas.CALLDATACOPY(destOffset, offset, length): copy calldata to memory. 3 + 3 per word + memory expansion.
Transactions pay 16 gas per non-zero calldata byte and 4 gas per zero byte (intrinsic). This is why “vanity addresses with leading zero bytes” save gas — a 4-zero-byte address shaves 48 gas off every call that includes it.
Audit reflex: contracts that read calldata via raw assembly (e.g., signature verification, Multicall routing) must validate CALLDATASIZE matches expected. Truncated calldata reads zero for missing bytes — a real exploit vector (Wormhole 2022 had a calldata-related bypass via uninitialized struct fields, [verify]).
6. Storage Layout — Slot Math
This section is the most important of the week. Every audit involves storage.
6.1 The rules in one table
| Solidity construct | Where it lives |
|---|---|
| State variable in slot order | Slot 0, 1, 2, … — sequential, in declaration order |
Multiple <32B vars in a row | Packed into one slot, lowest-declared at lowest byte |
T[k] (fixed array) | k consecutive slots from base |
T[] (dynamic array) at slot p | Slot p stores length; element i at keccak256(p) + i * size |
mapping(K => V) at slot p | Slot p is unused; value at keccak256(h(k) ‖ p) where h zero-pads k to 32 bytes |
string / bytes ≤ 31 bytes at slot p | Slot p holds data ‖ (length * 2) |
string / bytes ≥ 32 bytes at slot p | Slot p holds length * 2 + 1; data at keccak256(p), sequential |
struct | Fields in declaration order, packed where they fit |
| Inheritance | C3-linearized: base contract slots come first; current contract appends |
Note the asymmetric rule: for mappings, the key is hashed; for dynamic arrays, the slot itself is hashed.
6.2 Worked example — mapping of mappings
contract Vault {
uint256 public totalSupply; // slot 0
mapping(address => uint256) public balances; // slot 1
mapping(address => mapping(address => uint256)) public allowances; // slot 2
uint128 public feeBps; // slot 3, lowest 16 bytes
uint128 public lastUpdate; // slot 3, next 16 bytes (packed!)
}To find balances[alice]:
slot = keccak256(abi.encode(alice, uint256(1)))
= keccak256(0x000...alice || 0x000...01)
To find allowances[alice][bob]:
inner_slot = keccak256(abi.encode(alice, uint256(2)))
final_slot = keccak256(abi.encode(bob, inner_slot))
This is two keccak256 operations. The auditor’s mental drill: draw this on paper for any contract before reading the implementation. Then cast storage <contract> <slot> to verify.
6.3 Worked example — dynamic array
uint256[] public items; // slot 4items.lengthat slot4.items[0]atkeccak256(uint256(4)).items[1]atkeccak256(uint256(4)) + 1.items[i]atkeccak256(uint256(4)) + i.
(uint256(4) here means “the value 4 abi-encoded to 32 bytes”. Specifically the Solidity rule is: data starts at keccak256(p) where p is the base slot, big-endian, 32-byte-encoded.)
6.4 Packed structs — the silent landmine
struct Position {
uint128 amount; // slot N, low 16 bytes
uint64 startTime; // slot N, next 8 bytes
uint64 lockupEnd; // slot N, top 8 bytes → all in slot N
}
mapping(address => Position) public positions; // slot ppositions[user] occupies one slot: keccak256(user, p).
If a future upgrade widens amount to uint256, the struct no longer fits in one slot — it becomes two slots. Existing data is corrupted because reads of old layout produce different fields. This is the silent-landmine class of upgrade bug. See Tuan-05-Vulnerability-Classes-Part-1 §5.
6.5 EIP-1967 — the proxy slots you must memorize
To avoid collisions between a proxy and its implementation, EIP-1967 (Final) reserves three high-entropy slots. Memorize these. Auditors look them up dozens of times a week.
| Purpose | Slot | Derivation |
|---|---|---|
| Implementation | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc | bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) |
| Admin | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 | bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1) |
| Beacon | 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 | bytes32(uint256(keccak256("eip1967.proxy.beacon")) - 1) |
The - 1 after the keccak is paranoia: it pushes the slot off any value that could be coincidentally derived from a normal storage layout.
When reading any proxy contract on Etherscan, your first command is:
cast storage 0x<proxy> 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbcIf it returns nonzero, that’s the current implementation address. Verify the source matches what Etherscan shows.
7. The Opcodes Auditors Must Know Cold
Not all 140+ EVM opcodes are equally important. The following are the ones that appear in every audit; you should be fluent in their semantics, gas, and the bug class each enables.
7.1 The CALL family
flowchart LR subgraph Caller[Caller contract A] Storage_A[Storage A] end subgraph Callee[Callee contract B] Code_B[Code B] Storage_B[Storage B] end Caller -->|CALL| Code_B Caller -->|STATICCALL<br>read-only| Code_B Caller -.->|DELEGATECALL<br>runs B's code in A's storage| Code_B DELEGATE2[DELEGATECALL writes here] -.-> Storage_A
| Opcode | Hex | msg.sender in callee | Storage written | msg.value | Can transfer ETH |
|---|---|---|---|---|---|
| CALL (0xF1) | F1 | the caller (A) | callee’s | preserved or new | yes |
| CALLCODE (0xF2) | F2 | the caller (A) | caller’s | preserved or new | yes — deprecated; do not use |
| DELEGATECALL (0xF4) | F4 | the original caller (whoever called A) | caller’s | preserved from A | no |
| STATICCALL (0xFA) | FA | the caller (A) | none (revert on SSTORE) | always 0 | no |
Cost (post-2929): 2,600 cold / 100 warm for the call itself, plus per-byte cost for memory used to pass calldata, plus gas forwarded to callee.
Audit reflexes for each:
- CALL with value: the recipient can execute arbitrary code in their fallback / receive. Default reentrancy risk if state is updated after.
- CALLCODE: deprecated since EIP-2488 (still functional in legacy bytecode, removed in EOF). If you see it in modern code, it’s almost certainly a bug or copy-paste.
- DELEGATECALL: the callee’s code runs in caller’s storage. This is the foundation of upgradeable proxies — and a foot-gun the size of a city block (see Tuan-05-Vulnerability-Classes-Part-1 §4).
- STATICCALL: enforces read-only (reverts on SSTORE, LOG, CREATE, SELFDESTRUCT). Solidity uses STATICCALL for
viewandpureexternal calls. Note: a STATICCALL can still recurse through nested CALLs that try to write — those will revert. Use this for safer oracle reads.
7.2 The CREATE family
| Opcode | Hex | Address derivation |
|---|---|---|
| CREATE (0xF0) | F0 | keccak256(RLP(sender, sender_nonce))[12:] |
| CREATE2 (0xF5) | F5 | keccak256(0xff ‖ deployer ‖ salt ‖ keccak256(init_code))[12:] |
Both:
- Execute
init_codeto produce runtime bytecode. - Charge
32,000gas base + memory expansion +INITCODE_WORD_COST = 2per word (EIP-3860) + 200 per byte of deployed code. - Cap initcode size at 49,152 bytes (
2 * MAX_CODE_SIZE) per EIP-3860 (Final, Shanghai).
CREATE2 audit angle (covered fully in §9.3):
- Counterfactual addresses: you can deposit to an address before any code lives there.
- Address squatting: if two pairs of
(salt, init_code)produce the same address, downstream code that trusts “code at this address means X” can be fooled.
7.3 SELFDESTRUCT — reduced post-Cancun (EIP-6780)
Pre-Cancun (Mar 2024): SELFDESTRUCT(beneficiary) transferred all ETH to beneficiary and removed the account from state (code, storage, nonce all erased).
Post-Cancun (EIP-6780, Final): the semantics depend on whether the contract was created in the same transaction as the SELFDESTRUCT:
| Created same tx? | Effect |
|---|---|
| Yes | Old behavior: account deleted, ETH sent to beneficiary |
| No | Only ETH is transferred to beneficiary. Code, storage, nonce remain. |
Audit relevance:
- The classic Parity-2017 “kill the library” pattern no longer fully reproduces — the library contract’s code persists.
- But: if a contract is created and self-destructed in the same tx (e.g., via CREATE2 + immediate destruct), the slot is freed and a future CREATE2 with the same
(salt, init_code)can deploy different code at the same address. This is the “redeploy” trick used for sneaky upgrades and is a real audit concern when CREATE2 deployers are used. - Many older proxies and “destroyable” contracts assume pre-Cancun semantics. Audit the post-Cancun behavior explicitly.
7.4 LOG0–LOG4 — events
| Opcode | Topics | Use |
|---|---|---|
| LOG0 (0xA0) | 0 | Anonymous event (no signature topic) |
| LOG1 (0xA1) | 1 | One indexed field |
| LOG2 (0xA2) | 2 | Standard event with one indexed param |
| LOG3 (0xA3) | 3 | Standard event with two indexed params |
| LOG4 (0xA4) | 4 | Maximum: signature + 3 indexed params |
Cost: 375 + 8 * data_bytes + 375 * num_topics.
By Solidity convention:
- Non-anonymous events have
topic[0] = keccak256(event_signature). - Up to 3
indexedparameters → topics. The rest go indata. indexedreference types (string,bytes,struct) are stored askeccak256(value)in the topic, not the value itself. You can filter by hash but not recover the value from logs. Audit signal: events declaredindexedon long strings are often misused — the dApp thinks it can recover the string from logs and can’t.
7.5 SLOAD / SSTORE / MSTORE / MLOAD / KECCAK256
| Opcode | Cost | Notes |
|---|---|---|
| SLOAD | 2,100 (cold) / 100 (warm) | Storage read |
| SSTORE | See §4.4 table | Storage write |
| MLOAD / MSTORE | 3 + memory expansion | Memory access |
| KECCAK256 (0x20) | 30 + 6 * (word count) | Hash of memory range |
KECCAK256 is the cheapest robust cryptographic operation in the EVM and is used everywhere: slot derivation (mappings, dynamic arrays), event signatures, function selectors, EIP-712 digests, CREATE2 address derivation. Most security-critical opcode after the CALL family.
7.6 RETURNDATASIZE / RETURNDATACOPY — the return-data buffer
After every external call, the EVM exposes a return-data buffer containing the bytes returned by the callee. Two opcodes read it:
RETURNDATASIZE(0x3D): byte length of return data. 2 gas.RETURNDATACOPY(0x3E): copy a range into memory. 3 + 3 per word + expansion. Reverts if the requested range exceeds the buffer.
Audit class — return-data bombing: an attacker-controlled callee can return arbitrary bytes. If the caller does (bool ok, bytes memory ret) = target.call(data) and then iterates over ret, the callee can return megabytes (well, kilobytes given gas constraints) of zero bytes. The caller pays for the copy. Real DoS pattern — see Tuan-06-Vulnerability-Classes-Part-2.
Mitigation: when calling untrusted code, use assembly to ignore return data, or cap it: assembly { let s := returndatasize(); if gt(s, 32) { revert(0,0) } }.
7.7 EXTCODECOPY / EXTCODESIZE / EXTCODEHASH
EXTCODESIZE(addr): code size ofaddr. 2,600 (cold) / 100 (warm).EXTCODECOPY(addr, destOffset, offset, length): copyaddr’s code to memory. Same cold/warm + memory + copy cost.EXTCODEHASH(addr):keccak256(addr.code). Same cold/warm. Returns0for non-existent accounts,keccak256("")for accounts with no code.
EXTCODESIZE is the foundation of address.code.length == 0 checks. As covered in §2.2, this check has three honest answers (EOA, mid-constructor contract, 7702-delegated EOA) — never use it as a security guarantee on its own.
7.8 Block & tx context: BLOCKHASH / PREVRANDAO / TIMESTAMP / GASPRICE
| Opcode | Hex | Returns | Trust |
|---|---|---|---|
BLOCKHASH (0x40) | 40 | Hash of one of the last 256 blocks; 0 otherwise | Cannot be predicted before the block; can be withheld by proposer |
COINBASE (0x41) | 41 | Address of the current block’s proposer | Adversarial |
TIMESTAMP (0x42) | 42 | Unix time of the current block | Loosely bounded: must be > parent’s, within ~15s of wall clock (per consensus rules) [verify] |
NUMBER (0x43) | 43 | Current block number | Reliable in-context |
PREVRANDAO (0x44) | 44 | Previous block’s RANDAO mix (post-Merge; was DIFFICULTY pre-Merge) | Block proposer can selectively withhold/publish; gives them up to ~1 bit of bias per slot. Not safe as direct randomness — see EIP-4399 |
GASLIMIT (0x45) | 45 | Block’s gas limit | Per-chain |
CHAINID (0x46) | 46 | Chain ID | Cannot be spoofed in-tx |
BASEFEE (0x48) | 48 | Current block basefee (EIP-3198) | Reliable |
BLOBHASH (0x49) | 49 | Versioned hash of i-th blob (EIP-4844) | Per-tx |
BLOBBASEFEE (0x4A) | 4A | Current block blob basefee | Reliable |
Plus tx-scope: GASPRICE (0x3A; returns effective gas price under 1559), ORIGIN (0x32; tx originator).
Audit reflexes:
TIMESTAMPin a require-condition with a ~15-minute window is fine. Used as randomness or for fine-grained ordering: bug.PREVRANDAOas a lottery winner: bug (proposer can withhold).BLOCKHASH(block.number)always returns 0 (current block is in-progress).BLOCKHASH(block.number - 1)is the most recent valid one.tx.originis the EOA that started the transaction. Post-7702, it can be an EOA that delegates to contract code — so “istx.originan EOA?” is no longer a meaningful question. See §11.
7.9 GAS — read remaining gas
GAS (0x5A): pushes the gas remaining after this opcode itself. 2 gas.
Used by Solidity to compute gasleft(). Audit pattern: contracts that use gasleft() defensively (e.g., to abort if gas is low before an external call) must account for the 63/64 rule — the caller cannot force the callee to receive all of gasleft().
7.10 The 63/64 rule (EIP-150)
When CALL/STATICCALL/DELEGATECALL forwards gas, the EVM forwards at most 63/64 * gas_remaining. The caller always retains at least 1/64 to handle any post-call logic.
forwarded = min(requested, 63 * remaining / 64)
Bug class — gas grief: an attacker-controlled callee burns 100% of forwarded gas, leaving the caller with only remaining / 64 for the rest of the function. If the caller then needs to do an SSTORE (~5,000 gas), it reverts. Whole tx fails. The attacker has griefed the caller’s gas for free (their burn was paid by the caller).
Mitigation: cap forwarded gas explicitly when calling untrusted targets, e.g., target.call{gas: 100_000}(data).
8. ABI Encoding for Auditors
8.1 Function selectors
The first 4 bytes of every calldata to a contract function. Computed as:
selector = bytes4( keccak256( "functionName(type1,type2,...)" ) )
Rules:
- No spaces in the signature.
- Parameter types use canonical names:
uint256, notuint.int256, notint.address,bool,bytes,string,bytes32,uint8[],(uint256,bool)for tuples. - Return types are not part of the signature.
- Function modifiers (
view,payable) are not part of the signature.
Example:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = 0xa9059cbb
Audit relevance:
- Selector collisions are theoretically possible (4 bytes ≈ 4 billion). For two functions in the same contract, the compiler refuses to compile. For functions in different contracts called via a fallback router (Diamond, Multicall), collisions matter.
- Selector clashes in proxies were the historical bug class fixed by transparent proxies (the admin’s
upgradeTo()selector and the implementation’s selector must not match; transparent proxies branch on caller; UUPS branches by check). - Tools:
cast sig "transfer(address,uint256)",cast 4byte 0xa9059cbbfor reverse lookup.
8.2 abi.encode vs abi.encodePacked
abi.encode(args): produces canonical ABI encoding with length prefixes for dynamic types. Reversible via abi.decode. Always produces a unique byte string per input tuple.
abi.encodePacked(args): concatenates types without padding for fixed-size, without length prefix for dynamic. Can produce collisions when applied to variable-length types — covered in Week 01.
// SAFE — no collision possible
keccak256(abi.encode(a, b)) // distinct for any (a, b) ≠ (a', b')
// UNSAFE for variable-length types
keccak256(abi.encodePacked(a, b))The audit checklist item: flag every keccak256(abi.encodePacked(...)) whose argument list contains any string, bytes, dynamic-length array, or unfixed-size struct.
8.3 Encoding of dynamic types
ABI-encoded dynamic types use head + tail layout:
[fixed-size args + offsets to dynamic data] ← head
[lengths and data of dynamic args] ← tail
For example, function f(uint256 a, bytes b, uint256 c) with (42, "hello", 99):
0x000...002a // a = 42
0x000...0060 // offset of b (96 bytes from start)
0x000...0063 // c = 99
0x000...0005 // length of b = 5
0x6865...0000 // "hello" padded to 32 bytes
Audit class — offset trickery: a malicious caller can craft calldata where the offset of a dynamic type points outside the calldata, or overlaps another field. Solidity decoders validate this since 0.5.10+ but assembly-based decoders (Wormhole, Optimism’s bridges, many cross-chain message handlers) must validate manually. The Wormhole-style bridge bug class involved this.
9. Contract Creation, CREATE2, and Address Squatting
9.1 CREATE address derivation
addr = keccak256( RLP( sender, sender_nonce ) )[12:]
sender_nonce is the sender’s nonce at the time of the CREATE. For EOAs, this is the tx nonce. For contracts, this is the contract’s CREATE counter.
Predictable but moving — every CREATE shifts the next address.
9.2 CREATE2 address derivation
addr = keccak256( 0xff || deployer_address || salt || keccak256(init_code) )[12:]
Components:
0xffis a 1-byte literal prefix that ensures CREATE2 addresses cannot collide with CREATE addresses (CREATE inputs RLP-start with<0xff).deployer_address: 20 bytes.salt: 32 bytes, picked by the deployer.keccak256(init_code): 32 bytes — note the hash of init code, not the init code itself.
The full output is hashed; the last 20 bytes (i.e., [12:] in 32-byte indexing) are the address.
This formula is deterministic and independent of nonce — meaning anyone, anywhere, anytime can compute the address that will exist if a particular (deployer, salt, init_code) is deployed.
9.3 Address squatting
Two scenarios:
Scenario A — same (deployer, salt, init_code) redeployed after SELFDESTRUCT. Pre-Cancun, a contract at CREATE2 address could selfdestruct, freeing the slot; a future CREATE2 with the same inputs but different init code would deploy to the same address. Post-EIP-6780, the same-tx-create-and-destruct path still allows this.
Scenario B — attacker frontruns the legitimate deployment. If a protocol announces “we will deploy contract X at CREATE2 address Y from deployer D with salt S,” but the deployment is permissionless (anyone can call the deployer), an attacker calls first with a malicious init_code that happens to produce a colliding-but-different runtime code, or with the same expected init code that they then manipulate via constructor params.
Audit checklist:
- Who can call the deployer? Is
saltuser-controlled? - If counterfactual deposits are accepted at a CREATE2 address, what happens if no one ever deploys to that address? (Funds locked.)
- What happens if a different bytecode deploys at the expected address? (Trust assumption violation.)
- Is the deployment protected against same-tx selfdestruct-and-redeploy?
9.4 Initcode size limit (EIP-3860)
Final, Shanghai. Initcode (the bytecode passed to CREATE/CREATE2 that produces the runtime code) is capped at 49,152 bytes (2 × MAX_CODE_SIZE). Initcode also costs an additional 2 gas per 32-byte word to prevent quadratic jumpdest-analysis costs.
Audit relevance: rarely a bug cause, but factory contracts that batch-deploy many contracts must respect the limit. Some old factories did not.
10. EIP-7702: EOAs With Code (Pectra, May 2025)
10.1 What it actually does
EIP-7702 (Final, Pectra hard fork, May 7, 2025) introduces transaction type 0x04 carrying an authorization list. Each authorization is a tuple (chain_id, address, nonce, y_parity, r, s) signed by an EOA. When the tx executes:
- For each authorization, the EVM verifies the signature.
- It writes a delegation designator to the EOA’s code field:
0xef0100 ‖ delegate_address(23 bytes total). - Subsequent CALLs to the EOA execute
delegate_address’s code in the EOA’s context (similar to a per-EOA proxy).
The EOA’s private key still controls authorization — but the behavior of the EOA on incoming calls is now smart-contract-like.
10.2 Auditor implications
This is one of the most consequential changes to the threat model since The Merge:
| Old assumption | New reality |
|---|---|
address.code.length == 0 ⇒ EOA | EOA with 7702 delegation has code.length == 23 |
| EOA cannot have storage | An EOA delegated to a contract has the contract’s logic but writes to the EOA’s storage |
tx.origin == msg.sender ⇒ direct EOA call | Still true at the entry, but msg.sender might itself be running delegated code |
| EOA signs only the tx envelope | EOA signs the delegation authorization (a 7702 tuple) separately from any tx; phishing surface expands |
selfdestruct from an EOA impossible | A 7702-delegated EOA delegating to a contract with SELFDESTRUCT can be self-destructed — but per EIP-6780, only erases code, not storage; the EOA’s funds go to the beneficiary |
| Private key compromise drains the EOA’s tokens | + can authorize 7702 delegation to malicious code, persistently — even after the EOA “rotates” keys, the delegation remains until explicitly cleared |
Audit hot spots in 2025–2026:
- Any contract that uses
code.lengthas an EOA-vs-contract gate. - Any phishing flow that asks a user to “sign this message” — could be a 7702 authorization in disguise.
- Wallets must surface 7702 authorization signing in a comprehensible way; UX bugs here are direct exploit vectors.
- Tokens / protocols that branch behavior on
msg.sender.code.length(e.g., to disable hooks).
See Tuan-12-Wallet-AA-Key-Management for the deep dive.
10.3 ERC-4337 alongside 7702
ERC-4337 (Last Call EIP-status, but deployed in production via EntryPoint contracts since 2023) provides account abstraction without consensus changes — the EntryPoint singleton receives UserOperation structs from bundlers and dispatches them to smart-contract accounts.
The relationship:
| ERC-4337 | EIP-7702 |
|---|---|
| Account is a deployed smart contract | Account is an EOA with delegation designator |
| Tx originator is the bundler / EntryPoint | Tx originator is the EOA itself (or sponsor under 7702) |
| Requires a new contract per user | Reuses the user’s existing EOA address |
| Mature tooling (bundlers, paymasters) since 2023 | Tooling rapidly maturing in 2025–2026 [verify] |
Both coexist. Many wallets will route through 7702 for simple cases and 4337 for advanced features (paymasters, session keys, social recovery). The auditor needs to recognize each pattern and the corresponding threat model.
11. Lab — Reading a Real Mainnet Transaction End-to-End
11.1 Setup
You should have Foundry from Week 01. Verify:
forge --version
cast --versionSet up an RPC. You can use a free public endpoint (rate-limited) or one of Infura / Alchemy / QuickNode. Export:
export ETH_RPC_URL=https://eth.llamarpc.com # public, no key required; rate-limited
# Or: export ETH_RPC_URL=https://mainnet.infura.io/v3/<YOUR_KEY>Verify connection:
cast block-number
# Should print the current block numberCreate the lab directory:
mkdir -p ~/web3-sec-lab/wk02 && cd ~/web3-sec-lab/wk02
forge init --no-commit evm-lab
cd evm-lab11.2 Exercise 1 — Anatomy of a real transaction
Pick a transaction. A common, audit-relevant choice is a Uniswap V2 swap, e.g., go to https://etherscan.io/ and grab any recent swap transaction. For reproducibility, you can pick one yourself (recent activity is more verifiable than a fixed historical hash, which may no longer be retrievable from your RPC’s archive depth). The instructions below assume you have a tx hash in TX:
export TX=0x<your_chosen_tx_hash>Pull the transaction:
cast tx $TXExpected fields:
blockHash,blockNumber,from,to,gas,gasPrice(ormaxFeePerGas+maxPriorityFeePerGasfor Type-2),input,nonce,value,type(0x0,0x1,0x2,0x3,0x4),chainId.
Task: identify the transaction type. If it’s 0x2, note the basefee burned vs the tip paid:
# block basefee
cast block $(cast tx $TX --json | jq -r .blockNumber) --field baseFeePerGasCompute by hand:
effective_gas_price = basefee + min(maxPriority, maxFee - basefee)fee_paid = effective_gas_price * gasUsedburned = basefee * gasUsedto_proposer = (effective_gas_price - basefee) * gasUsed
11.3 Exercise 2 — Decode calldata
# The raw calldata
CALLDATA=$(cast tx $TX --json | jq -r .input)
echo $CALLDATA
# Extract the selector (first 4 bytes = first 10 hex chars including 0x)
SELECTOR=${CALLDATA:0:10}
echo "Selector: $SELECTOR"
# Reverse-lookup against the 4byte directory
cast 4byte $SELECTOR
# Prints candidate function signaturesIf cast 4byte returns a known signature, decode the full calldata:
cast 4byte-decode $CALLDATA
# Prints arguments by typeTask: write a one-paragraph “what this transaction does” based purely on the decoded calldata. Do not look at Etherscan’s decoded view yet. Then verify against Etherscan and note any divergence.
11.4 Exercise 3 — Read the receipt
cast receipt $TXNote:
status: 1 = success, 0 = revert.gasUsed: the gas actually consumed.logs[]: an array of{address, topics, data}. Each is an event.
Task: for each log, identify the emitting contract, the event signature from topics[0] (look it up with cast 4byte-event 0x<topic0>), and the indexed vs data fields. Write out the human-readable event list.
11.5 Exercise 4 — Trace the transaction
For an opcode-level trace:
cast run $TX
# Replays the tx locally against an RPC-provided state(cast run requires the RPC to support debug_traceTransaction. Many free providers don’t; use Tenderly’s public dashboard as a fallback at https://dashboard.tenderly.co/tx/.)
Task: identify the CALL boundaries — every time control passed to a different contract. For each CALL, note the target address, value transferred, and gas forwarded. This is the call tree of the transaction.
11.6 Exercise 5 — Read storage of a live contract
Pick a well-known contract — say, USDC’s proxy (0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 [verify it’s still the canonical USDC]). Read its EIP-1967 implementation slot:
PROXY=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
IMPL_SLOT=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
cast storage $PROXY $IMPL_SLOTThe output is the current implementation address (right-padded in a 32-byte word). Task: cross-reference with Etherscan’s “Read as Proxy” tab; they should match.
Now read a regular slot. The OpenZeppelin Ownable pattern stores the owner at slot derived from keccak256("openzeppelin.ownable.owner") minus one in some versions, or simply slot 0 in older versions. Examine the contract’s slot 0:
cast storage $PROXY 0x00If it’s nonzero, it could be the contract’s first state variable. Interpretation depends on the layout of the implementation contract — go look it up on Etherscan and compute the offset.
11.7 Exercise 6 — Storage slot math in Foundry
Write a Foundry test demonstrating mapping derivation:
// test/SlotMath.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
contract SlotMathTest is Test {
// Mirror this layout
// contract Vault {
// uint256 public totalSupply; // slot 0
// mapping(address => uint256) public balances; // slot 1
// mapping(address => mapping(address => uint256)) public allowances; // slot 2
// }
function test_mapping_slot() public pure {
address alice = address(0xA11CE);
// balances[alice] is at keccak256(abi.encode(alice, uint256(1)))
bytes32 slot = keccak256(abi.encode(alice, uint256(1)));
emit log_named_bytes32("balances[alice] slot", slot);
// Try this in cast: cast index address 0xA11Ce 1
}
function test_nested_mapping_slot() public pure {
address alice = address(0xA11CE);
address bob = address(0xB0B);
// allowances[alice][bob]
bytes32 inner = keccak256(abi.encode(alice, uint256(2)));
bytes32 outer = keccak256(abi.encode(bob, inner));
emit log_named_bytes32("allowances[alice][bob] slot", outer);
}
function test_dynamic_array_slot() public pure {
// uint256[] items at slot 4
// items[i] is at keccak256(uint256(4)) + i
bytes32 base = keccak256(abi.encode(uint256(4)));
bytes32 elem5 = bytes32(uint256(base) + 5);
emit log_named_bytes32("items[5] slot", elem5);
}
}Run:
forge test --match-contract SlotMathTest -vvCross-check with cast:
cast index address 0x000000000000000000000000000000000000A11Ce 1
# Should match the test's emitted balances[alice] slot11.8 Exercise 7 — Deploy with CREATE2 and verify the address
// src/Create2Lab.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Deployed {
uint256 public immutable seed;
constructor(uint256 _seed) {
seed = _seed;
}
}
contract Factory {
event Deployed_(address addr, bytes32 salt);
function deploy(bytes32 salt, uint256 seed) external returns (address addr) {
bytes memory bytecode = abi.encodePacked(
type(Deployed).creationCode,
abi.encode(seed)
);
assembly {
addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
if iszero(addr) { revert(0, 0) }
}
emit Deployed_(addr, salt);
}
// Precompute the address — no deployment
function computeAddress(bytes32 salt, uint256 seed) external view returns (address) {
bytes memory bytecode = abi.encodePacked(
type(Deployed).creationCode,
abi.encode(seed)
);
bytes32 hash = keccak256(abi.encodePacked(
bytes1(0xff),
address(this),
salt,
keccak256(bytecode)
));
return address(uint160(uint256(hash)));
}
}// test/Create2Lab.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Create2Lab.sol";
contract Create2LabTest is Test {
Factory factory;
function setUp() public {
factory = new Factory();
}
function test_predict_matches_deploy() public {
bytes32 salt = keccak256("first");
uint256 seed = 42;
address predicted = factory.computeAddress(salt, seed);
address deployed = factory.deploy(salt, seed);
assertEq(predicted, deployed, "CREATE2 address mismatch");
emit log_named_address("CREATE2 address", deployed);
}
function test_different_seed_different_address() public {
bytes32 salt = keccak256("same-salt");
address a = factory.computeAddress(salt, 1);
address b = factory.computeAddress(salt, 2);
assertTrue(a != b, "Same salt, different init code -> different address");
}
}Run:
forge test --match-contract Create2LabTest -vvStretch: redeploy at the same predicted address (will fail — CREATE2 at an occupied slot reverts). Add a selfdestruct to Deployed, deploy, destruct in the same tx (per EIP-6780 the slot frees), then redeploy at the same address with different init code. Observe the redeployment works only when create-and-destruct are in the same tx.
11.9 Exercise 8 — Decode a packed slot
Create a small contract with a packed struct:
// src/Packed.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Packed {
uint128 public a = 0x1111111111111111111111111111111111111111; // truncated to uint128 size
uint64 public b = 0x2222222222222222;
uint64 public c = 0x3333333333333333;
// a, b, c all in slot 0
}Deploy locally with anvil, then read slot 0:
anvil & # in another terminal
forge create src/Packed.sol:Packed --rpc-url http://localhost:8545 --private-key 0xac0974...
# (use a default anvil dev key)
cast storage <deployed_addr> 0x00 --rpc-url http://localhost:8545You’ll see all three values concatenated into one 32-byte word, with a in the low 16 bytes, b in the next 8, c in the top 8.
Task: manually decode the slot byte layout into the three values. This is the auditor’s everyday skill when reading proxy contracts whose source is not verified.
11.10 Expected learning outcome
After this lab you should:
- Be able to read any mainnet transaction with
castalone, identifying type, fees, called function, emitted events. - Read the EIP-1967 implementation slot of any proxy in one command.
- Compute a mapping slot in your head for
m[k]and on paper form[k1][k2]. - Predict a CREATE2 address before deployment and verify it.
- Decode a packed storage slot by hand.
If any of these is fuzzy, redo the relevant exercise.
12. Anti-Patterns Checklist (additions to your master list)
Add these to the checklist you started in Week 01:
- Uses
msg.sender.code.length == 0to enforce “EOA only” — breaks under mid-constructor calls and under EIP-7702 delegations. - Uses
tx.originfor access control — breaks under contract callers, ERC-4337, EIP-7702. - Reads
tx.gaspriceto refund users — under 1559 this iseffectiveGasPrice, not the user’s bid; refund math can be wrong by the basefee. -
abi.encodePackedover dynamic-length args in a hashed payload — collision risk. - Caches
block.chainidin domain separator at construction without recompute-on-fork — replay risk on chain forks. - Storage variable added in the middle of an existing layout for an upgradeable contract — silent layout shift.
- Struct widened (
uint128 → uint256) for an upgradeable contract — packing changes, silent corruption. - CREATE2 deployer accepts attacker-influenceable
saltorinit_codewithout enforcement — address squatting. - Counterfactual deposit address (CREATE2) with no guarantee the contract will be deployed — funds-locked risk.
-
extcodesizecheck used as security boundary — bypassed via constructor reentry. - External call result not bounded in copy size — return-data bombing.
- External call forwards all gas to user-supplied callee — gas-grief / 63-64.
-
selfdestruct-dependent logic that assumes pre-Cancun semantics (account fully deleted) — broken on mainnet post-Mar 2024. - Reentrancy guard uses two SSTOREs without considering transient storage option (cheap upgrade) — pure gas finding, but flags missed modernization.
-
indexed string/indexed bytesevent field with intent to recover the value from logs — only the hash is recoverable. - Function selector chosen from a non-canonical signature (e.g., uses
uintinstead ofuint256) — recompute the selector to confirm.
13. Trade-offs and Open Debates
| Decision | Option A | Option B | Auditor’s view |
|---|---|---|---|
| Reentrancy guard storage | Persistent SSTORE-based | Transient TSTORE-based (post-Cancun) | Transient is cheaper; persistent is more compatible across non-Cancun chains. For a multi-chain protocol, persistent is the safe default; for a Cancun-only protocol (Ethereum L1, latest L2s), transient is preferred. Verify all target chains support 1153. |
| Proxy pattern | Transparent | UUPS | UUPS saves gas but puts upgrade authority in the implementation — a bad upgrade that removes _authorizeUpgrade permanently freezes the proxy. Transparent isolates upgrade auth in admin. Audit the upgrade path either way. |
| Address generation for child contracts | CREATE | CREATE2 | CREATE2 enables counterfactual UX and deterministic addresses (Safe wallets, factory-deployed pools). CREATE is simpler and harder to abuse. Use CREATE2 only when the determinism is needed. |
| 7702 vs 4337 for AA | 7702 (delegate from EOA) | 4337 (separate smart account) | 7702: lower friction, reuses EOA; risks include persistent malicious delegation. 4337: stronger isolation, mature tooling. Many wallets in 2026 are converging on 7702 for simple cases, 4337 for complex (paymaster, session keys). |
| Calldata vs blob for L2 batches | Calldata (legacy) | Blob (4844) | Blob is 10-100× cheaper post-Cancun. Auditors must confirm the L2’s bridge contract can verify against blob commitments via the 0x0a precompile, and that DA-after-blob-pruning (~18 days) is acknowledged in the rollup’s trust model. |
address.code.length checks | Use them with caveats | Avoid entirely | Avoid as a security gate. Use them only for UX hints (e.g., “should I call a callback?”). For security: check explicit ownership / signatures. |
14. Quiz (≥80% to advance)
-
Q: An Ethereum account record stores four fields. Name them. A:
nonce,balance,storageRoot,codeHash. -
Q: A user broadcasts an EIP-1559 transaction with
maxFeePerGas = 50 gwei,maxPriorityFeePerGas = 2 gwei. The block’s basefee at inclusion is 30 gwei. The tx uses 100,000 gas. How much ETH is burned vs sent to the proposer? A:effective_gas_price = 30 + min(2, 50−30) = 32 gwei. Burned:30 gwei × 100,000 = 3,000,000 gwei = 0.003 ETH. Proposer:2 gwei × 100,000 = 200,000 gwei = 0.0002 ETH. -
Q: A contract has
mapping(uint256 => mapping(address => uint256)) public foodeclared at slot 5. What’s the storage slot offoo[42][alice]? A:inner = keccak256(abi.encode(uint256(42), uint256(5))), thenfinal = keccak256(abi.encode(alice, inner)). Two keccaks. -
Q: A function uses
require(msg.sender.code.length == 0, "no contracts")to enforce “EOA-only” callers. Name three scenarios that defeat this check. A: (1) A contract calling from inside its own constructor — code is not yet committed to state, socode.lengthis 0. (2) Post-EIP-7702: a 7702-delegated EOA hascode.length == 23so it fails the check — but the intent (block smart-contract-like behavior) is also defeated when the protocol naïvely permits the call from a “real” EOA that happens to have delegated to a malicious contract. (3)tx.origin == msg.senderis sometimes added as a substitute, but post-7702 the originator can itself be an account running delegated code. There is no robust “EOA-only” check; redesign around explicit signatures and access control. -
Q: An attacker can call your function which performs
(bool ok, bytes memory ret) = target.call(data). The target is attacker-controlled. What’s the attack on the caller? A: Return-data bombing — the attacker returns a very largeret, forcing the caller’sRETURNDATACOPYto expand memory quadratically and burn gas; alternatively, the attacker burns 99% of forwarded gas in a loop, leaving the caller with only 1/64 of pre-call gas (63/64 rule), insufficient for subsequent SSTOREs. -
Q: A contract uses CREATE2 with a hardcoded salt and init code. Why might a future audit still flag a security risk? A: If the contract being CREATE2’d contains a
SELFDESTRUCTreachable in the same transaction as its creation (e.g., constructor-triggered), the EIP-6780 same-tx exception allows the slot to free up and someone can redeploy different code at the same address. -
Q: Why does
block.prevrandaonot provide cryptographic randomness suitable for lottery payouts? A: The block proposer sees the candidate value before publishing the block. If publishing the block yields an unfavorable outcome for them, they can withhold and let another proposer take the slot. This gives ~1 bit of bias per slot per EIP-4399’s analysis. -
Q: What is the EIP-1967 implementation slot, and how is it derived? A:
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, equal tobytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1). -
Q: Transient storage (EIP-1153) costs 100 gas per TLOAD/TSTORE. Why would an auditor flag a reentrancy guard that uses transient storage as needing extra scrutiny — even though it’s cheaper? A: Cheap guards encourage developers to put guards everywhere, including on paths that legitimately need to reenter (callback-style flows). Also, “low-gas reentrancy” attacks exploit cheap state changes inside guards to defeat the lock semantics. The audit must verify that the design genuinely benefits from a lock at each guarded function — not just that the guard “looks free.”
-
Q: An EOA’s owner signs an EIP-7702 authorization delegating to a malicious contract. They later send all their ETH to a fresh wallet, believing they are “safe.” What is still wrong? A: The delegation designator remains on-chain in the EOA’s
codeHashfield until explicitly cleared (by signing a new 7702 authorization withaddress(0)or by sending a0x04tx that revokes). Any future incoming call to the abandoned EOA will execute the malicious code in the EOA’s context. The EOA address remains compromised even with zero balance — anyone who later sends to it will trigger the trap.
15. Week 02 Deliverables
- All Lab Exercises 1–8 completed; tests passing where applicable.
- Notes file
~/web3-sec-lab/wk02/notes.mdwith your written quiz answers. - One real mainnet transaction fully annotated: type, fees, decoded calldata, decoded receipt + events, call tree from a trace.
- A diagram (paper or Excalidraw) of: account record → state trie → storage trie → individual slot. Show how
cast storagetraverses this. - One paragraph: “If I were auditing an upgradeable protocol that uses CREATE2-deployed satellite contracts, what are the three storage-layout assumptions I would verify on day one?“
16. Where this leads
Next week: Tuan-03-Solidity-Foundry-Workflow. You move from “the EVM as a virtual machine” to “Solidity as the language we audit,” with the Foundry workflow as your everyday tool. Inheritance and storage layout (extended from this week), custom errors, immutables, transient-storage idioms (TLOAD/TSTORE patterns from EIP-1153), event design, and CI for contracts.
After that, Phase 2 begins with Tuan-04-Security-Foundations-CEI-AC — the first lesson where the storage / opcode model from this week becomes the substrate for actual vulnerability analysis. Every reentrancy bug we’ll write a PoC for in Week 05 is a story about: which opcode fired, in what order, under what gas conditions, against which storage layout.
The bricks of this week — accounts, transactions, opcodes, gas, slots — never go away. You’ll be using them on day 1 of every audit for the rest of your career.
Last updated: 2026-05-16 See also: Roadmap · References · MOC-Web3-Security-Mastery · Tuan-01-Web3-Blockchain-Crypto-Fundamentals · Tuan-03-Solidity-Foundry-Workflow · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-12-Wallet-AA-Key-Management