🌱 Beginner ⏱️ 25 min

Smart Contracts: Code That Holds Money

A smart contract is a program stored on the blockchain that automatically executes when predefined conditions are met. No bank. No lawyer. No middleman. The code is the agreement — and it holds real money.

The Vending Machine Analogy

The best mental model for a smart contract is a vending machine:

🦞 Vending Machine vs Smart Contract
Vending Machine: You insert $2.00 You press B4 (Chips) Machine automatically dispenses chips No cashier needed Rules are mechanical — cannot be bribed Smart Contract: You send 0.5 ETH You call buyToken(tokenId) Contract automatically transfers token to you No intermediary needed Rules are in code — cannot be bribed or overridden

The critical difference: the vending machine lives in one physical location. The smart contract lives on thousands of computers simultaneously and is enforced by cryptographic consensus — not by any single party.

How Smart Contracts Differ from Web APIs

FeatureTraditional Web APISmart Contract
Code is visible to everyone?No (private server-side code)Yes (verified on blockchain)
Can hold money?No (payments go to bank)Yes (ETH/tokens stored in contract)
Can be updated?Yes (redeploy anytime)No (immutable unless proxied)
Who controls execution?The company's serversThe Ethereum network (no one)
Can be censored?Yes (company decides)No (anyone can call it)
Bug discovered?Patch and redeployFork, upgrade proxy, or accept loss
Source of trustTrust the companyTrust the verified code
Who audits it?Company's QA teamExternal security auditors (public code)

Contract Deployment — What Actually Happens

🚀 Deployment Lifecycle
Step 1: Write Solidity source code You write: MyToken.sol Step 2: Compile to bytecode solc MyToken.sol --bin --abi → MyToken.bin (bytecode for EVM execution) → MyToken.abi (interface definition for callers) Step 3: Send a deployment transaction from: 0xYou to: null (no recipient = contract deployment) data: 0x6080604052... (your bytecode + constructor args) value: 0 (or initial ETH if constructor is payable) Step 4: EVM creates the contract Contract address = keccak256(rlp(deployerAddress, nonce))[12:] → 0xContractAddress is deterministic from deployer + nonce Bytecode is stored permanently at that address Step 5: Constructor runs ONCE Any initialization code runs Constructor is NOT stored — only the runtime bytecode remains Step 6: Contract is live Anyone can call it using its ABI Address never changes Code never changes (unless proxy pattern used)

The EVM — Stack Machine Execution Model

The Ethereum Virtual Machine is a stack-based machine. Unlike register-based CPUs, the EVM operates on a stack of 256-bit words. Understanding this helps when reading exploit traces and decompiled bytecode.

🖥️ EVM Stack Machine (Simplified)
Solidity: a + b * c Compiles to EVM opcodes: PUSH1 0x05 // Push 'c' (value 5) onto stack PUSH1 0x03 // Push 'b' (value 3) onto stack MUL // Pop b and c, push (b * c = 15) PUSH1 0x02 // Push 'a' (value 2) onto stack ADD // Pop a and (b*c), push (a + b*c = 17) Stack state visualized: After PUSH c: [5] After PUSH b: [3, 5] After MUL: [15] (3*5=15, both popped, 15 pushed) After PUSH a: [2, 15] After ADD: [17] (2+15=17, both popped, 17 pushed) Key properties: Stack depth: max 1,024 items (stack too deep = revert) Word size: 256 bits (32 bytes) — everything is uint256 internally Memory: byte-addressable, temporary (cleared after tx) Storage: persistent key-value store (survives transactions)

State Variables vs Local Variables — Storage Locations

📊 Storage Locations Explained
contract StorageExample { // STATE VARIABLES — stored in CONTRACT STORAGE (persistent, expensive) uint256 public totalSupply; // Lives at storage slot 0 address public owner; // Lives at storage slot 1 mapping(address => uint256) balances; // keccak256(key, slot) for each entry function example(uint256 input) external { // LOCAL VARIABLE — stored in MEMORY (temporary, cheap) uint256 localVar = input * 2; // Gone when function returns // CALLDATA — input data, read-only, cheapest // function params marked 'calldata' stay in the tx input buffer // Reading storage is expensive! Cache it: uint256 cachedSupply = totalSupply; // One SLOAD (2,100 gas) for (uint i = 0; i < 10; i++) { // Use cachedSupply (memory, 3 gas) not totalSupply (storage, 2,100 gas) doSomething(cachedSupply); } } }

A Complete Real Example — The Simple Bank

Here is a complete contract that demonstrates all the core concepts. We will walk through it line by line:

🏦 Simple Bank Contract — Full Annotated Example
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SimpleBank { // ═══ STATE VARIABLES (live in contract storage) ═══ // Tracks how much ETH each address has deposited mapping(address => uint256) private balances; // Who controls admin functions address public owner; // ═══ EVENTS (cheap storage in logs, not queryable by contracts) ═══ event Deposited(address indexed user, uint256 amount); event Withdrawn(address indexed user, uint256 amount); // ═══ CONSTRUCTOR (runs exactly once at deployment) ═══ constructor() { owner = msg.sender; // Deployer becomes owner } // ═══ DEPOSIT: Accepts ETH and records the balance ═══ function deposit() external payable { // msg.value: ETH sent with this transaction (in wei) require(msg.value > 0, "Must deposit something"); balances[msg.sender] += msg.value; emit Deposited(msg.sender, msg.value); } // ═══ WITHDRAW: Sends ETH back to the caller ═══ function withdraw(uint256 amount) external { // CHECKS: Verify preconditions require(balances[msg.sender] >= amount, "Insufficient balance"); // EFFECTS: Update state BEFORE the external call (prevents reentrancy) balances[msg.sender] -= amount; // INTERACTIONS: External call last (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); emit Withdrawn(msg.sender, amount); } // ═══ VIEW: Read-only, no state change, no gas when called externally ═══ function getBalance(address user) external view returns (uint256) { return balances[user]; } // ═══ FALLBACK: Catches ETH sent directly to contract address ═══ receive() external payable { // Called when ETH is sent with no data balances[msg.sender] += msg.value; emit Deposited(msg.sender, msg.value); } }

Contract Interactions: How Contracts Call Each Other

📞 Call Types — EOA, Contract, and Delegatecall
1. EOA → Contract (standard transaction) Alice sends tx → calls myContract.doSomething() msg.sender = Alice, msg.value = tx.value 2. Contract → Contract (external call) MyContract.doSomething() calls OtherContract.helper() Inside OtherContract: msg.sender = MyContract address 3. CALL: execute other contract's code in OTHER contract's context otherContract.call(data) → OtherContract's storage is modified → msg.sender = MyContract 4. DELEGATECALL: execute other contract's code in YOUR context otherContract.delegatecall(data) → YOUR storage is modified (not OtherContract's!) → msg.sender = original caller (preserved!) → This is HOW PROXY CONTRACTS WORK → Also how STORAGE COLLISION vulnerabilities happen
🚨 Security Implication: delegatecall Is Dangerous

delegatecall executes external code in your contract's own storage context. If the called contract has a storage layout that differs from yours, it can overwrite critical variables. The Parity Multisig wallet hack ($150M frozen) exploited delegatecall with an unprotected initialization function. Always audit the storage layout compatibility when using delegatecall.

Upgradability Patterns — Proxies and Their Risks

🔄 The Proxy Pattern (How Upgradeable Contracts Work)
Standard (non-upgradeable): User → MyContract (logic + storage in same contract) Cannot change logic without redeploying to new address Proxy Pattern: User → ProxyContract → ImplementationContract (stores data) (has the logic) ProxyContract: stores all state, delegates calls via delegatecall ImplementationContract: has all the logic, no state To "upgrade": admin changes which ImplementationContract proxy points to User address never changes — seamless for users Risks introduced by proxy pattern: 1. Storage collision: Proxy and Implementation must agree on storage layout 2. Initialization: Implementation's constructor doesn't run — must call init() 3. Centralization: Who controls the upgrade? Can they steal funds? 4. Selfdestruct: If implementation selfdestructs, proxy becomes useless 5. Function clashes: Proxy's functions (upgradeTo) vs Implementation's functions

Solidity Contract Structure Overview

📋 Full Contract Structure Template
// 1. License identifier (required) // SPDX-License-Identifier: MIT // 2. Compiler version pragma solidity ^0.8.20; // 3. Imports import "@openzeppelin/contracts/access/Ownable.sol"; // 4. Interfaces (define external contract ABIs) interface IToken { function transfer(address to, uint256 amount) external returns (bool); } // 5. Libraries // 6. Errors (custom errors, cheaper than strings) error InsufficientBalance(uint256 requested, uint256 available); // 7. Main contract contract MyContract is Ownable { // 7a. Type declarations enum Status { Active, Paused, Ended } // 7b. State variables uint256 public constant MAX_SUPPLY = 1_000_000; uint256 public immutable deployedAt; Status public status; // 7c. Events event StatusChanged(Status newStatus); // 7d. Modifiers modifier whenActive() { require(status == Status.Active); _; } // 7e. Constructor constructor() Ownable(msg.sender) { deployedAt = block.timestamp; status = Status.Active; } // 7f. Functions (external, public, internal, private) receive() external payable {} fallback() external payable {} }

Common Beginner Mistakes

⚠️ Mistake #1: Missing payable Keyword

If you want your function to receive ETH, mark it payable. Without it, any ETH sent with the call will cause the transaction to revert. Many tokens have been lost this way — users trying to send ETH to a non-payable contract address (not a function, but the contract directly) will have their transactions fail.

⚠️ Mistake #2: Wrong Visibility — Unintended Public Functions

Functions default to public in older Solidity (pre-0.5). In 0.8.x you must specify visibility — but beginner errors still happen. An admin function accidentally left public instead of onlyOwner means anyone can call it. The $34M Nomad bridge hack had a related root cause.

⚠️ Mistake #3: No Access Control on Critical Functions

Functions that change ownership, pause the contract, upgrade the implementation, or withdraw funds MUST be access-controlled. The most common audit finding: a function that should be onlyOwner but has no modifier at all.

🚨 Security Mindset: Every Line Is an Attack Surface

Traditional software security cares about data leaks and service outages. Smart contract security also cares about direct financial theft. Every line of Solidity code that touches ETH or ERC-20 tokens is potentially an attack surface. A security-conscious developer asks of every function: "What is the worst thing an adversarial caller could do here?" Before you write production smart contracts, have them audited by professionals.

Summary / Key Takeaways

ConceptKey FactSecurity Implication
Smart ContractImmutable code on blockchainBugs are permanent — audit before deploy
DeploymentOne-time bytecode storage at addressAddress fixed — cannot patch code
StoragePersistent key-value per contractExpensive to write, public to read
constructor()Runs once at deploy, not storedInit bugs are a common attack vector
delegatecallRuns code in caller's storage contextStorage collision can wipe state
Proxy patternUpgradeable contracts via delegatecallAdds centralization and storage risks
payableRequired to receive ETHMissing = ETH send reverts
receive()Handles plain ETH sendsCan be exploited for unexpected ETH