← Back to the library
Web3How it works · Deep dive · 16 min

The EVM and smart contracts, from bytecode to state

What does the EVM actually execute, where does contract data live, and why are external calls the dangerous part?

A smart contract is bytecode stored at an address, run by a deterministic 256-bit stack machine that every node executes identically. Each opcode costs gas, persistent storage is by far the most expensive place to put data, and every external call hands control to code you do not own.

THE MENTAL MODEL

Think of Ethereum as one replicated state machine: state S plus a transaction T deterministically produce S'. The EVM is the function that computes that transition, one opcode at a time, with no clock, no randomness and no I/O except what the transaction and the current state provide. Contracts are just accounts that carry code and a storage map of 32-byte slots; calling one means sending it ABI-encoded calldata whose first 4 bytes pick the function. Execution is all-or-nothing per call frame: a revert unwinds that frame's state changes and returns error data to the caller, who decides whether to bubble it up. The hard part is not arithmetic but control flow: the moment a contract calls out (to transfer ETH or tokens, or to query another protocol), the callee runs arbitrary code with your contract's state half-updated. Checks-effects-interactions and reentrancy guards exist because of that one fact.

HOW IT FITS TOGETHER

From Solidity source to world stateTop is what you write; bottom is what every node stores and hashes into the block header's state root.
  1. Solidity / Vyper sourceContracts, functions, events, custom errors. The compiler also emits the ABI JSON that clients use to encode calls.
  2. ABI: selectors and encodingcalldata = 4-byte selector (first 4 bytes of keccak256("transfer(address,uint256)")) + 32-byte-aligned arguments.
  3. EVM bytecodeInit code runs once at deployment and returns the runtime code; the runtime code is a dispatcher that jumps on the selector.
  4. EVM executionStack of 1024 256-bit words, byte-addressed memory per call frame, gas metered per opcode, revert unwinds the frame.
  5. Account statenonce, balance, codeHash, storageRoot. EOAs have no code (or a 7702 delegation indicator); contracts have code and storage.
  6. State trie (Merkle Patricia)All accounts and per-contract storage tries reduce to one state root committed in each block header.
An external call and a blocked reentrancy attemptThe vault applies effects before the ETH transfer and holds a transient-storage lock, so the re-entry reverts instead of withdrawing twice.
  1. Attacker EOA → Attacker contractattack()
  2. Attacker contract → Vaultwithdraw(1 ether)
  3. Vault → Guard (transient slot)nonReentrant: TLOAD 0, TSTORE 1Transient storage (EIP-1153) costs 100 gas and is cleared at the end of the transaction.
  4. Vault → VaultChecks + effects: balance[attacker] = 0State is updated before any external call (checks-effects-interactions).
  5. Vault → Attacker contractcall{value: 1 ether}("")Control leaves the vault. The callee's receive() can run arbitrary code with the forwarded gas.
  6. Attacker contract → Vaultreceive() re-enters withdraw()
  7. Vault → Guard (transient slot)nonReentrant: TLOAD 1
  8. Guard (transient slot) → VaultLocked: revert ReentrancyGuardReentrantCallEven without the guard, the zeroed balance would fail the check; the guard also protects cross-function reentry.
  9. Vault → Attacker contractInner frame revertsIf receive() lets the revert bubble, the outer call returns false and the vault's ok check reverts the whole transaction.
  10. Vault → Guard (transient slot)On exit: TSTORE 0
  11. Vault → Attacker contractReturns: paid at most once

KEY TERMS

Accounts: EOA, contract, 7702-delegated
Every address has a nonce and balance. An EOA is controlled by a secp256k1 key and has no code; a contract has immutable runtime code and storage and acts only when called. Since Pectra, EIP-7702 lets an EOA set its code to a delegation indicator (0xef0100 || address), so calls to it run another contract's code in the EOA's own storage and balance context. Its private key still has full control.
Storage, transient, memory, calldata
Storage is persistent 32-byte slots: a cold SLOAD costs 2100 gas, writing a zero slot to non-zero costs 20,000. Transient storage (TSTORE/TLOAD, 100 gas) lives for one transaction. Memory is scratch space per call frame, 3 gas per word plus a quadratic term as it grows. Calldata is the read-only input, priced per byte (16 non-zero, 4 zero, with an EIP-7623 floor for data-heavy transactions). These are mainnet costs as of Fusaka; Glamsterdam's EIP-8037 and EIP-8038 are scheduled to raise state creation and access costs. Pack and cache storage reads; that is where gas goes.
ABI encoding and selectors
A function selector is the first 4 bytes of keccak256 of the canonical signature, with types only and no spaces, e.g. transfer(address,uint256) is 0xa9059cbb. Arguments follow in 32-byte words, dynamic types by offset. Custom errors use the same encoding, so a revert's return data is selector plus arguments; Error(string) and Panic(uint256) are the built-in ones.
Events and logs
LOG opcodes write to the transaction receipt, not to state; contracts cannot read them. topics[0] is the event signature hash and up to three indexed parameters become further topics (filterable); the rest is ABI-encoded data. Logs are cheap relative to storage and are how indexers and frontends follow a contract, but they disappear on a reorg along with their block.
call, delegatecall, staticcall
CALL runs the target's code in the target's context with optional ETH. DELEGATECALL runs the target's code in the caller's context (same storage, msg.sender and msg.value), which is how proxies and libraries work and why storage layout must match. STATICCALL forbids any state change, including logs and value transfers; Solidity uses it for view calls.
Reverts and custom errors
A revert discards state changes of the current call frame and returns error data; gas used so far is still paid. Low-level call returns (false, data) instead of bubbling, so unchecked return values silently swallow failures. Custom errors (error InsufficientBalance(uint256 have, uint256 need)) are cheaper than revert strings and decode into typed values on the client.
Upgradeability
Code is immutable, so upgrades use a proxy: a small contract that delegatecalls an implementation address stored in an ERC-1967 slot. UUPS keeps upgrade logic in the implementation, Transparent in the proxy, Beacon shares one implementation pointer across many proxies. Upgrades trade immutability for an admin key: storage layout must stay append-only (ERC-7201 namespaces help) and initializers replace constructors.

IN YOUR STACK

Solidity · Solidity 0.8.27+ Server

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

contract Token {
    mapping(address => uint256) public balanceOf;
    event Transfer(address indexed from, address indexed to, uint256 value);
    error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
    error ERC20InvalidReceiver(address receiver);

    constructor(uint256 supply) {
        balanceOf[msg.sender] = supply;
        emit Transfer(address(0), msg.sender, supply);
    }

    function transfer(address to, uint256 value) external returns (bool) {
        require(to != address(0), ERC20InvalidReceiver(to));
        uint256 bal = balanceOf[msg.sender]; // one SLOAD, reused below
        if (bal < value) revert ERC20InsufficientBalance(msg.sender, bal, value);
        unchecked { balanceOf[msg.sender] = bal - value; } // safe: checked above
        balanceOf[to] += value;
        emit Transfer(msg.sender, to, value);
        return true;
    }
}
  • Error names follow ERC-6093, which OpenZeppelin 5.x uses, so tools decode them consistently. require(condition, CustomError()) needs Solidity 0.8.27+ (0.8.26 only with via-IR); on older compilers use if (...) revert.
  • This is a teaching subset: production tokens also need allowances, transferFrom and approval events. Use OpenZeppelin's ERC20 rather than rolling your own.
Solidity 0.8.27+ documentation (opens in new tab)

Solidity · Foundry (forge-std) Server

import {Test} from "forge-std/Test.sol";
import {Token} from "../src/Token.sol";
contract TokenTest is Test {
    event Transfer(address indexed from, address indexed to, uint256 value);
    Token token;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");
    function setUp() public {
        vm.prank(alice); // the next call (here: the deployment) comes from alice
        token = new Token(100);
    }
    function test_RevertWhen_BalanceTooLow() public {
        vm.expectRevert(abi.encodeWithSelector(Token.ERC20InsufficientBalance.selector, alice, 100, 101));
        vm.prank(alice);
        token.transfer(bob, 101);
    }
    function testFuzz_TransferMovesBalance(uint256 amount) public {
        amount = bound(amount, 0, 100);
        vm.expectEmit(address(token));
        emit Transfer(alice, bob, amount); // expected event, compared with the next one emitted
        vm.prank(alice);
        token.transfer(bob, amount);
        assertEq(token.balanceOf(bob), amount);
    }
}
  • expectRevert with the full encoded error asserts the arguments too; expectPartialRevert(selector) matches only the selector.
  • Parameters on test functions turn them into fuzz tests; bound() keeps inputs in range without discarding runs.
  • Cheatcodes such as prank and expectRevert apply to the next external call only.
Foundry (forge-std) documentation (opens in new tab)

TypeScript · viem 2 Client

import { createPublicClient, erc20Abi, formatUnits, http, type Address } from "viem";
import { mainnet } from "viem/chains";

const client = createPublicClient({ chain: mainnet, transport: http(process.env.RPC_URL) });

export async function tokenBalance(token: Address, holder: Address) {
  // One eth_call through Multicall3, both reads at the same block.
  const [balance, decimals] = await client.multicall({
    allowFailure: false,
    blockTag: "safe",
    contracts: [
      { address: token, abi: erc20Abi, functionName: "balanceOf", args: [holder] },
      { address: token, abi: erc20Abi, functionName: "decimals" },
    ],
  });
  return formatUnits(balance, decimals);
}

// Raw slot read: the ERC-1967 implementation slot of an upgradeable proxy.
const IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
export const implementationOf = (proxy: Address) =>
  client.getStorageAt({ address: proxy, slot: IMPL_SLOT });
  • Reads are eth_call: free, executed by one RPC node, not part of consensus until you check them against a block you trust. Pin a block tag when two reads must agree.
  • The ABI as a const gives typed args and return values (bigint for uint256). Never convert token amounts to number.
viem 2 documentation (opens in new tab)

TypeScript · viem 2 Client

import { BaseError, ContractFunctionRevertedError, parseAbi, type Address } from "viem";
import { publicClient } from "./client";

const abi = parseAbi([
  "function transfer(address to, uint256 value) returns (bool)",
  "error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)",
  "error ERC20InvalidReceiver(address receiver)",
]);

export async function previewTransfer(account: Address, token: Address, to: Address, value: bigint) {
  try {
    return await publicClient.simulateContract({ account, address: token, abi, functionName: "transfer", args: [to, value] });
  } catch (err) {
    const reverted = err instanceof BaseError
      ? err.walk((e) => e instanceof ContractFunctionRevertedError)
      : null;
    if (reverted instanceof ContractFunctionRevertedError) {
      const { errorName, args } = reverted.data ?? {};
      if (errorName === "ERC20InsufficientBalance") return { error: "insufficient", needed: args?.[2] };
      return { error: errorName ?? reverted.reason ?? "reverted" }; // Error(string) / Panic land in reason
    }
    throw err; // RPC, network or nonce problems are not contract reverts
  }
}
  • Decoding only works for errors present in the ABI you pass. Unknown selectors surface as raw data in reverted.raw; look them up or add the error to the ABI.
  • Simulate before sending so users see a meaningful reason instead of paying gas for a failed transaction.
viem 2 documentation (opens in new tab)

TypeScript · viem 2 Client

import { createPublicClient, erc20Abi, webSocket, type Address } from "viem";
import { mainnet } from "viem/chains";

const client = createPublicClient({ chain: mainnet, transport: webSocket(process.env.WS_RPC_URL) });

export function watchIncoming(token: Address, wallet: Address, onTransfer: (t: Incoming) => void) {
  return client.watchContractEvent({
    address: token,
    abi: erc20Abi,
    eventName: "Transfer",
    args: { to: wallet }, // filters on an indexed topic, server-side
    onLogs: (logs) => {
      for (const log of logs) {
        onTransfer({
          id: `${log.transactionHash}:${log.logIndex}`, // idempotency key
          from: log.args.from!, value: log.args.value!, blockNumber: log.blockNumber!,
        });
      }
    },
    onError: (error) => console.error("subscription failed; backfill with getLogs", error),
  });
}
type Incoming = { id: string; from: Address; value: bigint; blockNumber: bigint };
  • Only indexed parameters can be filtered by args. With a WebSocket transport viem uses eth_subscribe; with HTTP it polls filters or getLogs.
  • Treat live logs as provisional: dedupe by (txHash, logIndex), and credit anything valuable only after re-reading at the safe or finalized block.
  • viem reconnects the socket but does not re-create the subscription; onError fires and logs stop. Persist the last processed block, re-watch, and backfill with getLogs over the gap.
viem 2 documentation (opens in new tab)

Solidity · OpenZeppelin Contracts 5.x Server

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";

contract Vault is ReentrancyGuardTransient {
    mapping(address => uint256) public balances;
    error InsufficientBalance(uint256 available, uint256 requested);
    error TransferFailed();

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        uint256 available = balances[msg.sender];                          // checks
        if (available < amount) revert InsufficientBalance(available, amount);
        balances[msg.sender] = available - amount;                         // effects
        (bool ok, ) = payable(msg.sender).call{value: amount}("");         // interactions
        if (!ok) revert TransferFailed();
    }
}
  • ReentrancyGuardTransient (available since 5.1) keeps the lock in transient storage, so it costs far less than the storage-based ReentrancyGuard. It needs a chain with EIP-1153 (Cancun or later).
  • The guard only protects functions that carry the modifier. Read-only reentrancy (another protocol reading your half-updated state mid-call) needs nonReentrantView on views or careful ordering.
  • Checks-effects-interactions is the primary defense; the guard is the belt to its braces.
OpenZeppelin Contracts 5.x documentation (opens in new tab)

WHERE IT BITES

  • Calling out before updating state (send ETH, then zero the balance). Any external call, including token transfers with hooks such as ERC-777 or ERC-721 safeTransfer, can re-enter.
  • Ignoring the boolean from a low-level call or a non-reverting ERC-20 transfer; the failure is silent. Check it, or use SafeERC20.
  • Using tx.origin for authorization: any contract the user calls can act as them. Use msg.sender, and remember a 7702-delegated EOA can now be a contract caller too, so do not use extcodesize or tx.origin == msg.sender to detect humans.
  • Changing storage order or types in an upgradeable implementation, or leaving the implementation uninitialized. Both have led to lost or frozen funds.
  • Unbounded loops over storage arrays: gas grows with users until the function can no longer fit in a block.
  • Treating emitted events as the source of truth on the client without confirmations; logs disappear with a reorged block.

CLOSE THE AI. EXPLAIN THIS.

Explain, at the opcode level, why the order of 'update the balance' and 'send ETH' matters in a withdraw function, and what a transient-storage reentrancy guard adds on top of fixing the order.

SOURCES

Explainer reviewed