You've sent transactions, paid gas fees, maybe even read a Solidity contract or two. But when you hit "confirm" in MetaMask and a function executes on Ethereum, what is the network actually doing at the machine level? Most guides stop at "the EVM runs smart contracts." This article goes inside the execution — opcode by opcode, memory slot by memory slot — so you understand why gas costs what it does, why some calls revert, and what the EVM is actually computing.

The EVM Is a Stack Machine, Not a Computer

You've probably heard the EVM called "Ethereum's computer." That framing is misleading. The EVM is a stack-based virtual machine — closer to a calculator with a specific instruction set than to the CPU in your laptop. It has no registers, no file system, no persistent RAM between calls. Every execution starts from a clean slate: an empty stack, empty memory, and a pointer to the contract's bytecode.

The stack is the core data structure. It holds up to 1,024 items, each 256 bits (32 bytes) wide. Every arithmetic operation, every comparison, every jump condition pulls values from the top of this stack and pushes results back onto it. This 256-bit width isn't arbitrary — it matches the size of Ethereum addresses (160 bits, padded), Keccak-256 hashes, and uint256, Solidity's default integer type.

Beyond the stack, execution has access to two other data areas: memory (a byte-addressable, volatile scratch space that exists only during that execution) and storage (the contract's persistent key-value store on-chain). The cost difference between these two is enormous and explains most of the gas behavior you see.

Common mistake: Thinking the EVM "runs Solidity." It doesn't. The EVM executes raw bytecode — a sequence of single-byte operation codes. Solidity, Vyper, and other languages compile down to this bytecode. The EVM has no concept of function names, variable types, or inheritance. Those are compiler abstractions.

From Transaction to Opcode Execution

When your transaction reaches a validator, the EVM initializes an execution context: the caller address, the value (ETH) sent, the calldata (your function call encoded as bytes), and the gas limit you provided. It then loads the target contract's bytecode from state and begins reading it from byte position 0.

Each byte maps to an opcode — the EVM's instruction set has around 140 defined opcodes. PUSH1 (0x60) pushes one byte onto the stack. ADD (0x01) pops two values, pushes their sum. SSTORE (0x55) writes a 32-byte value to storage. The program counter advances through the bytecode sequentially unless a JUMP or JUMPI opcode redirects it.

The first thing nearly every compiled contract does is route your call. The calldata's first 4 bytes are the function selector — a truncated Keccak-256 hash of the function signature (e.g., transfer(address,uint256)0xa9059cbb). The bytecode compares this selector against a series of known values using EQ and JUMPI to find the right code block. This is the function dispatch logic, and it's why calling a nonexistent function hits the fallback or reverts.

  • You can see a contract's raw bytecode on Etherscan under the "Contract" tab → "Bytecode"
  • Tools like [evm.codes](https://www.evm.codes/) let you look up every opcode, its gas cost, and its stack behavior
  • The [Ethereum Yellow Paper](https://ethereum.github.io/yellowpaper/paper.pdf), Appendix H, is the canonical opcode reference

Common mistake: Assuming function selectors are unique. They're only 4 bytes — collisions exist. The selector 0xa9059cbb maps to transfer(address,uint256), but a malicious contract could define a different function with the same 4-byte hash. This is why you verify contract source code, not just the selector.

By the numbers
140
the evm instruction set
0x
set has around defined
60
has around defined opcodes
0x
pushes one byte onto

Gas: Not a Fee, a Metering System

The standard explanation says gas is "the fee you pay for computation." What's actually happening is more specific: gas is a deterministic cost assigned to every opcode that prevents infinite execution and prices state access.

Each opcode has a fixed base gas cost. ADD costs 3 gas. MUL costs 5. SLOAD (reading from storage) costs 2,100 gas for a cold access (first read in that transaction) or 100 gas for a warm access (already touched). SSTORE (writing to storage) costs 20,000 gas when writing a non-zero value to a fresh slot, or 5,000 gas when updating an existing non-zero value. These numbers come from the current fee schedule post-EIP-2929 and EIP-3529.

Memory costs scale quadratically. The first 724 bytes are cheap (3 gas per 32-byte word). Beyond that, the cost grows with the square of the memory size. This is why contracts that build large arrays in memory can blow through gas unexpectedly — the per-word cost accelerates.

The EVM decrements your remaining gas with every opcode. If it hits zero, execution halts with an out-of-gas revert. All state changes from that execution are rolled back, but the gas is still consumed — the validator did the work.

  • Check actual gas consumed by any transaction on Etherscan → Transaction Details → "Gas Used by Transaction"
  • Compare against the "Gas Limit" field to see how much headroom was provided
  • [evm.codes](https://www.evm.codes/) lists current gas costs per opcode, including the cold/warm distinction

Common mistake: Thinking a failed transaction "wastes" gas due to a bug. The gas is payment for computation the network performed. The revert means the EVM executed opcodes, found a failing condition (like a REQUIRE check), and rolled back state — but the execution still happened.

Storage, Memory, and the Calldata Triangle

Three data locations, three completely different cost profiles and lifetimes:

  • Calldata: The input bytes sent with your transaction. Read-only during execution. Cheapest to access (copies cost 3 gas per byte plus memory expansion). This is why Solidity uses calldata for external function parameters when possible — it avoids copying to memory.
  • Memory: Volatile scratch space. Allocated during execution, discarded after. Byte-addressable and grows dynamically, but that quadratic cost curve means unbounded growth is dangerous.
  • Storage: Persistent, on-chain, stored in the global state trie. Reading a cold slot costs 2,100 gas. Writing costs 5,000–20,000 gas. This is the most expensive operation the EVM performs, because every storage write must be propagated across every node maintaining Ethereum's state.

When Solidity compiles a contract, it maps each state variable to a storage slot — a 256-bit key in the contract's storage. Slot 0 holds the first declared variable, slot 1 the second, and so on (with packing rules for types smaller than 32 bytes). Mappings and dynamic arrays use Keccak-256 hashes of the slot number and key to compute their storage location.

You can read any contract's storage directly. On Etherscan, the "Read Contract" tab uses the ABI to format this, but under the hood it's just SLOAD calls. You can also query raw storage slots using eth_getStorageAt via any RPC provider — no ABI needed.

Common mistake: Believing that private variables in Solidity are hidden. "Private" only means other contracts can't read them via the ABI. The storage slot is on-chain, readable by anyone with eth_getStorageAt. Nothing in contract storage is secret.

By the numbers
2,100
state trie reading cold
5,000
old slot costs gas
20,000
costs gas writing costs
256
tate variable storage slot

External Calls: How Contracts Talk to Each Other

When a contract calls another contract — a token approval triggering a swap, a lending protocol checking an oracle — the EVM creates a new sub-context. The calling contract's execution pauses. A fresh stack and memory are allocated for the callee. The callee runs its bytecode, then returns data and control to the caller.

The opcode CALL (0xF1) handles this. It takes seven stack arguments: gas to forward, target address, ETH value, input data offset/length in memory, and output data offset/length. The caller specifies how much of its remaining gas to forward — this is why you sometimes see gas estimation failures cascade across multiple contract calls.

DELEGATECALL (0xF4) works differently in a critical way: it runs the callee's bytecode but in the caller's storage context. This is how proxy patterns work — the proxy contract delegates to an implementation contract, which modifies the proxy's storage, not its own. If you've ever interacted with an upgradeable contract, every function call was a DELEGATECALL.

STATICCALL (0xFA) enforces read-only execution. If the callee attempts any state-modifying opcode (SSTORE, CREATE, SELFDESTRUCT, LOG, or another non-static CALL with value), execution reverts. View functions in Solidity compile to STATICCALL when called externally.

Common mistake: Assuming a view function is always safe to call. The function might be view in its own contract, but if it makes an external call to an untrusted contract via STATICCALL, that callee could still consume arbitrary gas or revert. Read-only doesn't mean cheap or risk-free.

How the Execution Ends: Return, Revert, or Invalid

Execution terminates in one of three ways:

  • RETURN: The opcode RETURN (0xF3) halts execution, returns specified memory bytes to the caller, and all state changes persist. This is the successful path.
  • REVERT: The opcode REVERT (0xFD), introduced in EIP-140, halts execution, returns error data, and rolls back all state changes from this execution context. Remaining gas is refunded. Solidity's require() and revert() statements compile to this.
  • Out-of-gas / Invalid opcode: Execution halts, all state changes roll back, and all remaining gas is consumed. No data is returned. This is the worst-case outcome for the caller — maximum cost, no information about what went wrong.

The distinction between REVERT and out-of-gas matters for debugging. A REVERT with return data usually means the contract hit a known failure condition and can tell you why (Solidity encodes the error string in the return data). An out-of-gas or invalid opcode gives you nothing — you're left tracing the execution to find the problem.

  • Use Etherscan's "Internal Transactions" tab to see sub-calls and where they failed
  • [Tenderly](https://tenderly.co/) and [Phalcon by Blocksec](https://phalcon.blocksec.com/) let you step through transactions opcode by opcode
  • Foundry's cast run --trace gives local opcode-level traces of historical transactions

Common mistake: Treating all transaction failures the same. A revert at the top-level call means the outermost execution failed. But a revert in a sub-call doesn't necessarily fail the whole transaction — the calling contract might catch and handle the failure using a try/catch pattern or by checking CALL's success return value.

By the numbers
0x
hree ways return the
0x
ssful path revert the
140
the opcode xfd introduced

Next Steps

  • Trace a real transaction: Pick any transaction on Etherscan, open it in [Tenderly's debugger](https://dashboard.tenderly.co/explorer), and step through the opcodes. Match what you see to the concepts above — stack operations, storage reads, sub-calls.
  • Read raw storage: Use cast storage (from [Foundry](https://book.getfoundry.sh/)) or eth_getStorageAt to read a contract's storage slots directly. Start with slot 0 of a simple ERC-20 and work out what's stored there.
  • Study the gas schedule: Read [EIP-2929](https://eips.ethereum.org/EIPS/eip-2929) (cold/warm access costs) and [EIP-1559](https://eips.ethereum.org/EIPS/eip-1559) (base fee mechanics). These two EIPs explain most of the gas behavior you encounter daily.
  • Decompile a contract: Paste unverified bytecode into [dedaub.com/decompile](https://app.dedaub.com/decompile) or [heimdall-rs](https://github.com/Jon-Becker/heimdall-rs) and see how raw opcodes map back to something readable. This closes the loop between Solidity and what the EVM actually runs.