Episode 6 — Scaling Reliability Microservices Web3 / 6.10 — Web3 Basics
6.10.d — Smart Contracts
In one sentence: A smart contract is self-executing code deployed on a blockchain that automatically enforces the terms of an agreement — once deployed, it runs exactly as programmed, without intermediaries, downtime, or censorship.
Navigation: <- 6.10.c — Decentralized Applications | 6.10.e — Cryptocurrencies ->
1. What Are Smart Contracts?
A smart contract is a program that lives on a blockchain. Think of it as a backend function that:
- Runs on thousands of computers simultaneously (decentralized)
- Cannot be modified after deployment (immutable)
- Executes automatically when conditions are met (self-executing)
- Is visible to everyone (transparent)
TRADITIONAL CONTRACT (legal):
- Written in English
- Enforced by courts and lawyers
- Can be interpreted differently
- Slow to execute (weeks/months)
- Requires trust in legal system
SMART CONTRACT (code):
- Written in Solidity (or Vyper, Rust)
- Enforced by the blockchain
- Executes exactly as written
- Fast to execute (seconds/minutes)
- Requires trust in code only
Real-World Analogy: The Vending Machine
A vending machine is like a smart contract:
1. You insert money (send cryptocurrency)
2. You select an item (call a function)
3. The machine checks:
- Did you insert enough money? (condition check)
- Is the item in stock? (state check)
4. If yes → dispense item + change (execute + return)
5. If no → return money (revert)
No cashier needed. No trust needed. The machine enforces the rules.
2. Solidity Language Basics
Solidity is the most widely used language for writing smart contracts on Ethereum and EVM-compatible chains (Polygon, Arbitrum, Base, etc.).
2.1 Contract Structure
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20; // Compiler version
// A contract is like a class in JavaScript
contract SimpleStorage {
// State variables — stored permanently on the blockchain
// (like database columns — each read/write costs gas)
uint256 public storedNumber;
address public owner;
// Constructor — runs ONCE during deployment
constructor() {
owner = msg.sender; // msg.sender = whoever deployed the contract
}
// Functions — the contract's API
function store(uint256 _number) public {
storedNumber = _number;
}
function retrieve() public view returns (uint256) {
return storedNumber;
}
}
2.2 Data Types
// ========================================
// PRIMITIVE TYPES
// ========================================
bool isActive = true; // Boolean
uint256 count = 42; // Unsigned integer (0 to 2^256-1)
int256 temperature = -10; // Signed integer
address wallet = 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045; // 20-byte Ethereum address
string name = "Hello Web3"; // UTF-8 string (dynamic, expensive)
bytes32 hash = keccak256("data"); // Fixed-size byte array (cheap)
// ========================================
// COMPLEX TYPES
// ========================================
// Arrays
uint256[] public numbers; // Dynamic array
uint256[10] public fixedNumbers; // Fixed-size array
// Mappings (like a hash map — the most used data structure)
mapping(address => uint256) public balances;
// balances[0xAlice] = 100
// balances[0xBob] = 50
// Nested mappings
mapping(address => mapping(address => uint256)) public allowances;
// allowances[owner][spender] = amount
// Structs
struct User {
string name;
uint256 balance;
bool isActive;
}
mapping(address => User) public users;
// Enums
enum Status { Pending, Active, Completed, Cancelled }
Status public currentStatus = Status.Pending;
2.3 Functions
contract FunctionExamples {
uint256 public value;
// PUBLIC — callable by anyone (external or internal)
function setValue(uint256 _value) public {
value = _value;
}
// VIEW — reads blockchain state but doesn't modify it (FREE to call externally)
function getValue() public view returns (uint256) {
return value;
}
// PURE — doesn't read or write blockchain state (FREE to call externally)
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
// EXTERNAL — only callable from outside the contract (slightly cheaper gas)
function externalOnly() external view returns (address) {
return msg.sender;
}
// INTERNAL — only callable from within the contract or derived contracts
function _internalHelper() internal pure returns (uint256) {
return 42;
}
// PRIVATE — only callable from within this contract (not even derived contracts)
function _secret() private pure returns (string memory) {
return "hidden";
}
// PAYABLE — can receive ETH with the function call
function deposit() public payable {
// msg.value = amount of ETH sent with this call
// The ETH is now held by the contract
}
}
2.4 Modifiers
Modifiers are reusable conditions that run before (or after) a function. They are similar to middleware in Express.
contract ModifierExamples {
address public owner;
bool public paused;
constructor() {
owner = msg.sender;
}
// Modifier — like Express middleware
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_; // ← this is where the actual function body executes
}
modifier whenNotPaused() {
require(!paused, "Contract is paused");
_;
}
// Only the contract owner can call this
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
// Stack modifiers — both must pass
function sensitiveAction() public onlyOwner whenNotPaused {
// only runs if caller is owner AND contract is not paused
}
function togglePause() public onlyOwner {
paused = !paused;
}
}
2.5 Events
Events are log entries emitted by smart contracts. They are cheap to write and are the primary way DApp frontends listen for changes.
contract EventExamples {
// Declare events (like TypeScript interface for logs)
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
// "indexed" parameters can be filtered/searched efficiently
// Up to 3 indexed parameters per event
function transfer(address to, uint256 amount) public {
// ... transfer logic ...
// Emit the event (stored in transaction logs, NOT in contract state)
emit Transfer(msg.sender, to, amount);
}
}
// Listening for events from JavaScript (ethers.js)
const contract = new ethers.Contract(address, abi, provider);
// Listen for all Transfer events
contract.on('Transfer', (from, to, value, event) => {
console.log(`${from} sent ${ethers.formatEther(value)} to ${to}`);
});
// Query historical events
const filter = contract.filters.Transfer(myAddress, null);
const events = await contract.queryFilter(filter, -10000); // last 10000 blocks
3. Smart Contract Lifecycle
SMART CONTRACT LIFECYCLE:
1. WRITE Developer writes Solidity code
│
▼
2. COMPILE Solidity compiler → bytecode + ABI
│ (solc or Hardhat/Foundry)
│
│ Bytecode: machine code for the EVM
│ ABI: JSON interface (like an API schema)
▼
3. TEST Run tests locally against a simulated blockchain
│ (Hardhat network, Foundry's Anvil)
▼
4. AUDIT Security review by professionals
│ (critical for contracts handling real money)
▼
5. DEPLOY Send a transaction with the bytecode
│ (costs gas — contract deployment is the most expensive operation)
│
│ Contract receives a permanent address:
│ 0x1234...5678
▼
6. VERIFY Publish source code on Etherscan
│ (so anyone can read and verify the code matches the bytecode)
▼
7. INTERACT Users and other contracts call functions
│ via transactions (write) or calls (read)
▼
8. LIVE FOREVER The contract runs as long as the blockchain exists
(it cannot be deleted — only "self-destructed" in some cases)
The ABI (Application Binary Interface)
The ABI is the JSON interface of a smart contract — it tells JavaScript how to talk to the contract:
// ABI for an ERC-20 token (simplified)
const abi = [
// Read functions (free)
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)",
// Write functions (cost gas)
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
// Events
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)"
];
// ethers.js uses the ABI to encode/decode function calls
const contract = new ethers.Contract('0xContractAddress', abi, provider);
const name = await contract.name(); // ethers encodes "name()" → sends to blockchain → decodes result
4. The EVM (Ethereum Virtual Machine)
The EVM is the runtime environment for smart contracts. Every Ethereum node runs an EVM, and every EVM executes the same code to reach the same result.
HOW THE EVM WORKS:
Solidity Code:
function add(uint a, uint b) returns (uint) { return a + b; }
│
▼ (compilation)
Bytecode (machine code):
6080604052... (hex instructions)
│
▼ (execution on every node)
EVM executes each instruction:
PUSH1 0x60 ← push value to stack
PUSH1 0x40 ← push another value
ADD ← pop two values, push sum
SSTORE ← store result (costs gas!)
│
▼
Result: same output on ALL nodes (deterministic)
CRITICAL: The EVM is deterministic.
Same input → same output → all nodes agree on the state.
This is why smart contracts can't use random numbers or external APIs directly.
EVM-Compatible Chains
Many blockchains run the EVM, meaning Solidity contracts can be deployed to any of them:
EVM-Compatible chains:
- Ethereum (the original)
- Polygon (sidechain)
- Arbitrum (Layer 2 rollup)
- Optimism (Layer 2 rollup)
- Base (Layer 2, Coinbase)
- Avalanche C-Chain
- BNB Smart Chain (BSC)
- Fantom
Non-EVM chains (different smart contract languages):
- Solana (Rust)
- Near Protocol (Rust/AssemblyScript)
- Cosmos (Go)
- Cardano (Haskell/Plutus)
5. Gas Optimization Basics
Every EVM operation has a gas cost. Optimizing gas usage reduces transaction fees for users.
OPERATION GAS COSTS (approximate):
ADD / SUB / MUL: 3 gas (arithmetic — very cheap)
SLOAD (read storage): 2,100 gas (reading state — expensive)
SSTORE (write storage): 20,000 gas (writing new) / 5,000 gas (updating)
CREATE (deploy contract): varies (most expensive)
Simple transfer: 21,000 gas
ERC-20 transfer: ~65,000 gas
Uniswap swap: ~150,000 gas
Common Gas Optimization Techniques
// BAD: Reading storage in a loop (2,100 gas each read)
function sumBad() public view returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < myArray.length; i++) { // myArray.length reads storage each iteration!
total += myArray[i]; // myArray[i] reads storage!
}
return total;
}
// GOOD: Cache storage values in memory
function sumGood() public view returns (uint256) {
uint256[] memory arr = myArray; // One storage read, copy to memory
uint256 len = arr.length; // Cache length
uint256 total = 0;
for (uint256 i = 0; i < len; i++) {
total += arr[i]; // Reading from memory (3 gas vs 2,100)
}
return total;
}
// GAS TIP: Use uint256 instead of smaller types (uint8, uint16)
// The EVM operates on 256-bit words. Smaller types require extra
// operations to mask/pad, actually costing MORE gas in many cases.
// GAS TIP: Use events instead of storage for data you only need to read off-chain
// SSTORE: 20,000 gas | LOG (event): ~375 gas per topic + 8 gas per byte
// GAS TIP: Pack structs to use fewer storage slots
// BAD (3 storage slots = 60,000 gas to write):
struct UserBad {
uint256 id; // slot 0 (256 bits)
bool isActive; // slot 1 (8 bits, but takes full 256-bit slot)
uint256 balance; // slot 2
}
// GOOD (2 storage slots = 40,000 gas to write):
struct UserGood {
uint256 id; // slot 0
uint256 balance; // slot 1
bool isActive; // packed into slot 1 (still room in the slot)
}
6. Common Patterns
6.1 ERC-20 Tokens
ERC-20 is the standard interface for fungible tokens (currencies, utility tokens, governance tokens). Every token from USDC to UNI follows this standard.
// Simplified ERC-20 Token
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MyToken {
string public name = "MyToken";
string public symbol = "MTK";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(uint256 _initialSupply) {
totalSupply = _initialSupply * 10**decimals;
balanceOf[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) public returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(balanceOf[from] >= amount, "Insufficient balance");
require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
}
6.2 ERC-721 NFTs
ERC-721 is the standard for non-fungible tokens. Each token has a unique ID and a single owner.
// Simplified ERC-721 (key concepts only)
contract SimpleNFT {
string public name = "SimpleNFT";
string public symbol = "SNFT";
uint256 private _tokenIdCounter;
mapping(uint256 => address) public ownerOf;
mapping(address => uint256) public balanceOf;
mapping(uint256 => string) private _tokenURIs;
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
function mint(address to, string memory tokenURI) public returns (uint256) {
uint256 tokenId = _tokenIdCounter++;
ownerOf[tokenId] = to;
balanceOf[to]++;
_tokenURIs[tokenId] = tokenURI;
emit Transfer(address(0), to, tokenId);
return tokenId;
}
function transfer(address to, uint256 tokenId) public {
require(ownerOf[tokenId] == msg.sender, "Not the owner");
balanceOf[msg.sender]--;
balanceOf[to]++;
ownerOf[tokenId] = to;
emit Transfer(msg.sender, to, tokenId);
}
function tokenURI(uint256 tokenId) public view returns (string memory) {
return _tokenURIs[tokenId];
}
}
7. Security Considerations
Smart contract security is critical because bugs can lead to irreversible loss of funds. Hundreds of millions of dollars have been lost to smart contract exploits.
7.1 Reentrancy Attack
The most famous smart contract vulnerability. An attacker's contract calls back into the victim contract before the first call completes.
// VULNERABLE CONTRACT
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// BUG: Sends ETH BEFORE updating balance
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0; // Too late! Attacker already called withdraw() again
}
}
// ATTACKER CONTRACT
contract Attacker {
VulnerableVault public vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() public payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
// This function is called when the vault sends ETH
receive() external payable {
if (address(vault).balance > 0) {
vault.withdraw(); // Re-enter! Balance hasn't been zeroed yet
}
}
}
// SECURE VERSION — Checks-Effects-Interactions pattern
contract SecureVault {
mapping(address => uint256) public balances;
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// 1. CHECK — verify conditions (done above)
// 2. EFFECT — update state FIRST
balances[msg.sender] = 0;
// 3. INTERACTION — external call LAST
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
7.2 Common Vulnerabilities
| Vulnerability | Description | Prevention |
|---|---|---|
| Reentrancy | Attacker calls back into contract before state updates | Checks-Effects-Interactions pattern, ReentrancyGuard |
| Integer overflow | Numbers wrap around (pre-0.8.0) | Solidity 0.8+ has built-in overflow checks |
| Access control | Missing onlyOwner or role checks | Use OpenZeppelin AccessControl |
| Front-running | Attacker sees pending tx, submits their own first | Commit-reveal schemes, flashbots |
| Denial of Service | Attacker blocks a function from executing | Pull over push pattern |
| Oracle manipulation | Attacker manipulates price feed data | Use Chainlink oracles, TWAP |
| Unchecked return | Ignoring return value of external call | Always check return values |
| Delegate call | Unexpected storage layout in proxy patterns | Careful proxy implementation |
7.3 Auditing Importance
WHY AUDITING MATTERS:
The DAO Hack (2016): $60 million stolen via reentrancy
Parity Wallet (2017): $150 million locked forever (self-destruct bug)
Wormhole Bridge (2022): $320 million stolen (signature verification bug)
Ronin Bridge (2022): $600 million stolen (compromised validators)
AUDIT PROCESS:
1. Internal review — developers review each other's code
2. Automated tools — Slither, Mythril, Echidna (static analysis + fuzzing)
3. Professional audit — firms like OpenZeppelin, Trail of Bits, Certora
4. Bug bounty — Immunefi platform, rewards for finding vulnerabilities
5. Formal verification — mathematical proofs of correctness (for critical contracts)
COST:
Professional audit: $10,000 - $500,000+ depending on complexity
Bug bounty: up to millions for critical findings
Compare to: cost of an exploit = potentially ALL funds in the contract
8. Interacting with Smart Contracts from JavaScript
Reading Contract Data (Free)
import { ethers } from 'ethers';
const provider = new ethers.JsonRpcProvider('https://eth-mainnet.g.alchemy.com/v2/KEY');
// USDC contract
const usdcAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const usdcAbi = [
'function name() view returns (string)',
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
'function totalSupply() view returns (uint256)',
'function balanceOf(address) view returns (uint256)'
];
const usdc = new ethers.Contract(usdcAddress, usdcAbi, provider);
// All of these are free (no gas) because they are "view" functions
const name = await usdc.name(); // "USD Coin"
const symbol = await usdc.symbol(); // "USDC"
const decimals = await usdc.decimals(); // 6
const totalSupply = await usdc.totalSupply();
const balance = await usdc.balanceOf('0xSomeAddress...');
console.log(`${name} (${symbol})`);
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);
Writing to a Contract (Costs Gas)
// Sending a token transfer (requires a signer/wallet)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
const usdcWithSigner = new ethers.Contract(usdcAddress, [
'function transfer(address to, uint256 amount) returns (bool)'
], signer);
// Send 100 USDC (USDC has 6 decimals)
const amount = ethers.parseUnits('100', 6); // 100000000
const tx = await usdcWithSigner.transfer('0xRecipient...', amount);
console.log('Transaction submitted:', tx.hash);
// Wait for confirmation
const receipt = await tx.wait();
console.log('Confirmed!', {
blockNumber: receipt.blockNumber,
gasUsed: receipt.gasUsed.toString(),
status: receipt.status === 1 ? 'Success' : 'Failed'
});
9. Development Environment
Hardhat (JavaScript/TypeScript)
Hardhat is the most popular smart contract development framework:
Features:
- Local blockchain for testing (Hardhat Network)
- Solidity compilation
- Testing with Mocha/Chai
- Deployment scripts
- Etherscan verification
- Plugin ecosystem
Commands:
npx hardhat compile — compile contracts
npx hardhat test — run tests
npx hardhat node — start local blockchain
npx hardhat run scripts/deploy.js — deploy
Foundry (Rust-based, newer)
Foundry is gaining popularity for its speed and Solidity-native testing:
Features:
- Write tests in Solidity (not JavaScript)
- Extremely fast compilation and testing
- Built-in fuzzing
- Gas snapshots
- Cast CLI for blockchain interactions
Commands:
forge build — compile contracts
forge test — run tests
forge test --gas-report — see gas costs
anvil — start local blockchain
cast call 0xAddr "func()" — call contract from CLI
10. Key Takeaways
- Smart contracts are immutable backend code — once deployed, they execute exactly as written. This makes testing and auditing critical before deployment.
- Solidity is JavaScript-like but fundamentally different — every operation costs gas, state is permanent, and bugs can lose real money.
- The lifecycle is write -> compile -> test -> audit -> deploy -> verify — skipping the audit step for contracts handling value is reckless.
- ERC-20 and ERC-721 are the foundational standards — understanding these two interfaces covers most token and NFT interactions.
- Security is paramount — reentrancy, access control, and oracle manipulation are the most common attack vectors. Use established patterns (Checks-Effects-Interactions, OpenZeppelin libraries).
- Gas optimization matters — storage reads/writes are expensive; use memory, cache values, emit events instead of storing data you only need off-chain.
- ethers.js bridges JavaScript and smart contracts — Provider for reading, Signer for writing, Contract for interacting.
Explain-It Challenge
- Explain the Checks-Effects-Interactions pattern to a junior developer and why the ORDER of operations matters in smart contracts but not in typical backend code.
- A startup wants to launch an ERC-20 token. Walk them through the full lifecycle from writing the contract to users being able to trade it.
- Why can't smart contracts access external APIs (like a weather API) directly? How do oracles solve this, and what new trust assumptions do they introduce?
Navigation: <- 6.10.c — Decentralized Applications | 6.10.e — Cryptocurrencies ->