Skip to content

EVM and Solidity

The EVM (Ethereum Virtual Machine) is a deterministic stack machine that every node runs identically. Solidity is the language most contracts are written in. A transaction is a signed request that makes the EVM run one contract function and pay for every instruction in gas.

Bitcoin (2009) could only move coins. Ethereum (2015) put a general-purpose virtual machine on the same kind of replicated ledger, so anyone can deploy a program that every node executes. Two problems follow from “strangers run your code”:

  • Runaway code. A loop that never ends would stall every node. The fix is gas: every instruction has a price, the sender sets a budget, and execution stops when the budget runs out.
  • Fee chaos. Fees were a blind auction until EIP-1559 (proposed 2019, live since 2021) split the fee into a protocol-set base fee and a tip.

Later changes worth knowing: Solidity 0.8 made arithmetic revert on overflow by default. EIP-1153 added transient storage that is cleared at the end of the transaction. EIP-7702 (Pectra upgrade, May 2025) lets a normal wallet delegate to contract code.

  1. Sign. The wallet or signer builds the transaction and signs it with the private key. Fields: from, to, value, data (calldata: 4-byte selector plus ABI-encoded arguments), nonce, gas limit, maxFeePerGas and maxPriorityFeePerGas (EIP-1559), chainId, signature (v, r, s).
  2. Send. The signed bytes go to an RPC node, the API endpoint. The node checks the signature, the nonce and that the sender can pay.
  3. Gossip. The node passes the transaction into the public mempool. Anyone can watch it there before it lands.
  4. Bid. Block builders pick transactions and order them, mostly by fee.
  5. Seal. The transaction lands in a block. Ethereum produces one block every 12 seconds.
  6. Execute. Every node runs the call through the EVM. The transaction pays an intrinsic cost of 21,000 gas before any code runs, then gas per opcode. If anything reverts, all state changes roll back and the gas spent is still paid.
  7. Wait. The block becomes final about 13 minutes later. Until then a reorg can drop it. See Chain mechanics.
YOUR SIDE PUBLIC NETWORK Wallet / signersigns with key RPC nodeAPI endpoint Mempoolpublic queue Block builderorders by fee Blockevery 12 s Finalised≈13 min later send gossip bid seal wait blind signingBybit, Radiant lying RPCKelpDAO front-runningMEV sandwiches reorg riskuntil finalised
One transaction's journey, and where each attack class bites. The two left boxes are the part you run. Everything right of them is public, and anyone can watch a pending transaction before it lands.
  • EOA (externally owned account): controlled by a private key, has a nonce, no code.
  • Contract account: code plus storage. It acts only when called.
  • Since EIP-7702 (Pectra, May 2025) an EOA can delegate to contract code, so “EOA means no code” is no longer safe.

The nonce gives ordering and replay protection per account. It must increase by exactly one each time, so a gap blocks every later transaction from that sender. The chainId binds the signature to one chain.

The function to run is picked by the selector: the first 4 bytes of the Keccak-256 hash of the function signature. For transfer(address,uint256) that is 0xa9059cbb. The arguments follow, ABI-encoded in 32-byte words.

  • Every opcode (a single EVM instruction) costs gas. Storage writes cost the most.
  • Fee paid = gas used × (base fee + tip). The protocol sets the base fee per block and moves it by at most 12.5% from one block to the next, depending on how full the previous block was. maxFeePerGas caps what the sender will pay in total per gas unit. maxPriorityFeePerGas is the tip.
  • All transactions in a block must fit under the block gas limit. An unbounded loop over an array that users can grow can hit that limit and lock a function forever.
  • msg.sender is the immediate caller.
  • tx.origin is the EOA that started the transaction.
  • Never authorise on tx.origin. A malicious contract that the owner calls can then act as the owner.
  • Mutability: view reads state, pure neither reads nor writes, payable accepts ETH.
  • Special functions: receive() runs on plain ETH transfers, fallback() runs for unknown selectors.
  • Modifiers wrap functions with a check: onlyOwner, nonReentrant, whenNotPaused.
  • Errors: require and revert CustomError() roll back the whole call.
  • Events are logs. They are cheap, contracts cannot read them, and they are what monitoring watches: Upgraded, RoleGranted, OwnershipTransferred.

Solidity 0.8 and later reverts on overflow, except inside unchecked {} and in assembly. There are no floats: everything is an integer scaled by decimals (ETH 18, USDC 6). Division rounds down. Who the rounding favours is a security decision.

A contract’s storage is a key-value store of 32-byte slots, numbered from 0 in the order variables are declared. Everything in storage is readable by anyone, including variables marked private. private only stops other contracts from reading it through Solidity.

Type Controlled by Has code Can start a transaction Note
EOA A private key No Yes The classic wallet
Contract account Its own code Yes No, it only reacts to calls Has its own storage and balance
EOA with EIP-7702 delegation A private key, running delegated code A pointer to a contract Yes Breaks checks that assume an EOA has no code
Contract wallet (Safe, ERC-4337 smart account) Code that checks owners’ signatures Yes No, someone must submit for it Signatures are checked through ERC-1271
Opcode Whose code runs Whose storage it touches msg.sender seen by the code Use
call Target’s Target’s The calling contract Ordinary calls and ETH transfers
delegatecall Target’s The caller’s The original caller Proxies and libraries. The most dangerous opcode
staticcall Target’s None, state changes are not allowed The calling contract view and pure calls

Low-level calls return (bool, bytes) and do not revert on failure. Check the bool.

Location Lifetime Cost Note
storage Permanent Expensive 32-byte slots, public to everyone
memory One call Cheap Scratch space
calldata One call Cheapest Read-only input
transient (EIP-1153) One transaction Cheap Cleared at the end of the transaction; used for cheap reentrancy locks
Visibility Callable from outside Callable from inside Callable by derived contracts
external Yes Only through this.f() Only through this.f()
public Yes Yes Yes
internal No Yes Yes
private No Yes No

Anything external or public is reachable by anyone on the internet unless a modifier or a require stops them.

The commands use cast from Foundry and assume --rpc-url or ETH_RPC_URL is set.

Symptom Cause Check
Transactions from one address stop landing Nonce gap, or an earlier transaction is stuck with a low fee cast nonce $ADDR against cast nonce $ADDR --block pending
Transaction reverted and you need to know where A require or custom error fired somewhere in the call tree cast run $TX_HASH replays it and prints the trace
“Out of gas” on a function that used to work A loop over an array that has grown cast receipt $TX_HASH gasUsed, then read the loop bounds
Unknown calldata in a transaction someone asks you to sign You cannot tell what it does from the hex cast 4byte-calldata $CALLDATA or cast decode-calldata "f(address,uint256)" $CALLDATA
A “secret” stored in a private variable leaked Storage is public cast storage $CONTRACT 0 reads slot 0
A low-level call failed without anyone noticing The returned bool was ignored Slither detector unchecked-lowlevel
An owner-only function was called by a stranger’s contract Authorisation on tx.origin Slither detector tx-origin
A check for “caller is an EOA” let a contract through EIP-7702 delegation: the EOA now runs code cast code $ADDR returns 0xef0100 followed by the delegate address

The contract sends ETH first and zeroes the balance after. Sending ETH runs the receiver’s code, which calls withdraw() again while the balance is still full.

// Reentrant
function withdraw() external {
uint amt = bal[msg.sender];
(bool ok,) = msg.sender.call{value: amt}("");
require(ok);
bal[msg.sender] = 0; // too late
}
// Checks, effects, interactions, plus a guard
function withdraw() external nonReentrant {
uint amt = bal[msg.sender]; // checks
bal[msg.sender] = 0; // effects
(bool ok,) = msg.sender.call{value: amt}("");
require(ok); // interactions
}

The runtime sequence, the variants and the real cases are in Contract attacks.

EVM, Solidity, bytecode, opcode, EOA, contract account, EIP-7702, Pectra, transaction, calldata, ABI, function selector, Keccak-256, nonce, chainId, gas, gas limit, base fee, priority fee, maxFeePerGas, maxPriorityFeePerGas, EIP-1559, block gas limit, storage, memory, calldata, transient storage, EIP-1153, storage slot, msg.sender, tx.origin, call, delegatecall, staticcall, external, public, internal, private, view, pure, payable, receive, fallback, modifier, onlyOwner, nonReentrant, whenNotPaused, require, revert, custom error, event, unchecked, overflow, decimals, wei, checks-effects-interactions, cast, Foundry.