Solidity From Zero: Learn by Building (State, Functions, Events, Security, and a Capstone)
A friendly, thorough guide for absolute beginners. We start at “what is a smart contract?” and walk you to intermediate skills: writing, testing, and shipping small but real contracts safely.
- A “Hello, Blockchain” contract
- A simple ETH vault (deposit and withdraw)
- An address book with structs, arrays, and mappings
- A capstone: a safe mini crowdfunding contract with refunds
Along the way you will learn events, errors, modifiers, payable functions, data locations, CEI (Checks–Effects–Interactions), and reentrancy protection.
1) The mental model: what actually happens on-chain
Think of a smart contract as a tiny, public database with functions attached. Anyone can read it for free. Changing it costs gas. When you deploy a contract, its code is stored on-chain. Every time someone calls a function that writes state, miners/validators execute your function on a network computer, and the result becomes part of the chain’s history.
- State lives on-chain inside your contract. Examples: a number, an owner address, or a mapping of balances.
- Transactions are signed messages that call a function and (optionally) send ETH.
- Gas is the fee for computing and storing data on-chain. Complex operations and storage writes cost more.
- Events are logs that off-chain apps can “listen to” (they do not change state).
2) Your sandbox: Remix (no installs) or Foundry/Hardhat
Fastest start: Remix — go to remix.ethereum.org in your browser. Create a file, paste code, compile, and deploy to a JavaScript VM (a fake local chain) in seconds. Perfect for learning.
Local dev (optional, for later):
- Foundry: blazing-fast compile and test. Install with
curl -L https://foundry.paradigm.xyz | bash
, thenforge init myproj
. - Hardhat: popular toolchain with JS/TS, plugins, and scripts.
npm i -D hardhat
, thennpx hardhat
.
3) Warm-up: Hello, Blockchain
Let us store a greeting on-chain and update it. Paste this into Remix and deploy.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title HelloBlockchain - minimal state + function example /// @notice Stores and updates a message contract HelloBlockchain { string public message; // public gives you a free getter /// @dev Runs once at deployment constructor(string memory initialMessage) { message = initialMessage; } /// @notice Update the message (anyone can call this) function setMessage(string calldata newMessage) external { message = newMessage; } }
Try: Deploy with “gm, world”. Call message()
(free read). Call setMessage("hello")
to write (costs gas in a real network).
string public message
create a getter? Solidity auto-generates a read-only function for public state variables.4) State, types, and data locations
Solidity has value types (numbers, bools, addresses) and reference types (arrays, strings, mappings, structs). Reference types come with data locations that affect gas and behavior:
storage
: persistent on-chain state (expensive to write)memory
: temporary, copied for function work (cheap and transient)calldata
: read-only function input forexternal
functions (zero copy, cheapest for big arrays/strings)
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract TypesAndState { // Value types uint256 public count; // unsigned integer bool public live = true; address public owner; // Reference types string private note; // dynamic bytes + UTF-8 convention bytes32 public configHash; // fixed 32-byte data uint256[] public numbers; // dynamic array mapping(address => uint256) public score; // hash table (no length) struct User { string name; uint64 joinedAt; uint256 balance; } mapping(address => User) public users; constructor() { owner = msg.sender; } function setNote(string calldata n) external { // calldata is efficient for external inputs note = n; // copies calldata -> storage (costs gas) } function pushNumber(uint256 n) external { numbers.push(n); // write to storage } function setUser(string calldata name) external { users[msg.sender] = User({ name: name, joinedAt: uint64(block.timestamp), balance: 0 }); } function viewUser(address a) external view returns (User memory) { // copies storage struct -> memory and returns it return users[a]; } }
Mappings: You cannot iterate them by default and they have no length. If you need enumeration, store the keys in a separate array or emit events and index off-chain.
constant
for truly constant values (inlined, zero storage) and immutable
for values set once in the constructor.5) Functions: visibility, mutability, and payable
Function visibility controls who can call:
public
: callable by everyone and from insideexternal
: callable by others, not internally unless you usethis.fn()
; cheaper for big inputsinternal
: callable only from this contract or childrenprivate
: callable only from this exact contract
Mutability controls “write vs read”:
view
: reads state; no writespure
: reads nothing; pure math- (no keyword): can write state
payable
lets a function receive ETH via msg.value
. Contracts that accept raw ETH should also include receive()
and/or fallback()
functions (more below).
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract PayableExample { mapping(address => uint256) public balanceOf; /// @notice Deposit ETH to credit your internal balance function deposit() external payable { require(msg.value > 0, "send ETH"); balanceOf[msg.sender] += msg.value; } /// @notice Withdraw ETH you previously deposited function withdraw(uint256 amount) external { uint256 bal = balanceOf[msg.sender]; require(amount <= bal, "not enough"); balanceOf[msg.sender] = bal - amount; // Effects (bool ok, ) = msg.sender.call{value: amount}(""); // Interactions last require(ok, "transfer failed"); } /// @notice Current ETH in this contract function contractBalance() external view returns (uint256) { return address(this).balance; } // Optional: receive plain ETH with empty calldata receive() external payable { balanceOf[msg.sender] += msg.value; } }
6) Events and custom errors (and why they matter)
Events are logs for the outside world (front-ends, indexers). They are not state, but they are cheap and searchable. Up to three parameters can be indexed
so you can filter by address or ID efficiently.
event Deposited(address indexed user, uint256 amount); event Withdrawn(address indexed user, uint256 amount);
Custom errors reduce gas compared to long require("string")
messages and are more structured.
error InsufficientBalance(uint256 requested, uint256 available); error ZeroAmount(); function deposit() external payable { if (msg.value == 0) revert ZeroAmount(); // ... } function withdraw(uint256 amount) external { uint256 bal = balanceOf[msg.sender]; if (amount > bal) revert InsufficientBalance(amount, bal); // ... }
require
for quick prototypes. Switch to custom errors in production to save gas and standardize failure reasons.7) Modifiers and access control (safely gating power)
Modifiers let you attach reusable checks to functions. Classic case: only owner can do X.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract WithOwner { address public immutable owner; error NotOwner(); constructor() { owner = msg.sender; } modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } function ownerThing() external onlyOwner { // privileged action } }
For real projects, prefer audited access libraries like OpenZeppelin’s Ownable
, AccessControl
, Pausable
, and ReentrancyGuard
. They solve easy-to-miss edge cases and give events for changes (e.g., ownership transfer).
8) Core safety patterns (CEI, pull payments, try/catch)
A) CEI = Checks → Effects → Interactions
Validate inputs and permissions (Checks), update your own state (Effects), then call out (Interactions). This lowers reentrancy risk.
B) Pull payments over push
Instead of sending ETH mid-function (“push”), record credit and let users withdraw (“pull”). Failures then do not lock your logic.
mapping(address => uint256) public pending; function credit(address user, uint256 amt) internal { pending[user] += amt; // Effects } function claim() external { uint256 amt = pending[msg.sender]; pending[msg.sender] = 0; (bool ok,) = msg.sender.call{value: amt}(""); require(ok, "claim failed"); }
C) try/catch for external calls
Wrapping external calls lets you handle reverts gracefully and fall back to safer behavior.
9) Two small projects: Vault and AddressBook
Project 1 — Minimal ETH Vault
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title Minimal ETH Vault (educational) /// @notice Deposit and withdraw ETH you deposited contract MiniVault { mapping(address => uint256) private _bal; event Deposited(address indexed user, uint256 amount); event Withdrawn(address indexed user, uint256 amount); error ZeroAmount(); error Insufficient(uint256 requested, uint256 available); function deposit() external payable { if (msg.value == 0) revert ZeroAmount(); _bal[msg.sender] += msg.value; // Effects emit Deposited(msg.sender, msg.value); // Log for UI } function withdraw(uint256 amount) external { uint256 bal = _bal[msg.sender]; if (amount > bal) revert Insufficient(amount, bal); _bal[msg.sender] = bal - amount; // Effects (bool ok,) = msg.sender.call{value: amount}(""); // Interactions require(ok, "transfer failed"); emit Withdrawn(msg.sender, amount); } function balanceOf(address a) external view returns (uint256) { return _bal[a]; } receive() external payable { _bal[msg.sender] += msg.value; emit Deposited(msg.sender, msg.value); } }
Try it: Deposit 1 ether, withdraw 0.3 ether, then check balanceOf
. Watch events in Remix’s “Logs.”
Project 2 — Address Book with structs and mappings
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title MyAddressBook - demonstrates structs, arrays, mappings contract MyAddressBook { struct Contact { string name; string tag; address wallet; uint64 addedAt; } // Each user keeps their own list mapping(address => Contact[]) private _contacts; event ContactAdded(address indexed owner, address indexed wallet, string name, string tag); event ContactUpdated(address indexed owner, uint256 indexed idx, string name, string tag); event ContactRemoved(address indexed owner, uint256 indexed idx); error IndexOutOfBounds(); function addContact(string calldata name, string calldata tag, address wallet) external { _contacts[msg.sender].push(Contact({ name: name, tag: tag, wallet: wallet, addedAt: uint64(block.timestamp) })); emit ContactAdded(msg.sender, wallet, name, tag); } function updateContact(uint256 idx, string calldata name, string calldata tag) external { if (idx >= _contacts[msg.sender].length) revert IndexOutOfBounds(); Contact storage c = _contacts[msg.sender][idx]; // storage pointer c.name = name; c.tag = tag; emit ContactUpdated(msg.sender, idx, name, tag); } function removeContact(uint256 idx) external { Contact[] storage arr = _contacts[msg.sender]; if (idx >= arr.length) revert IndexOutOfBounds(); // swap and pop to delete without shifting many elements arr[idx] = arr[arr.length - 1]; arr.pop(); emit ContactRemoved(msg.sender, idx); } function listContacts() external view returns (Contact[] memory) { return _contacts[msg.sender]; } function count() external view returns (uint256) { return _contacts[msg.sender].length; } }
This shows structs, dynamic arrays, and storage references. Swap-and-pop is a common way to delete array elements cheaply.
10) Interfaces and calling other contracts
Many apps interact with tokens and protocols. Use interfaces to describe the external functions you call. Example: transfer ERC20 tokens from the caller into your contract.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; interface IERC20 { function transferFrom(address from, address to, uint256 value) external returns (bool); } contract TokenSink { event Taken(address indexed token, address indexed from, uint256 amount); function take(IERC20 token, uint256 amount) external { // The caller must have approved this contract beforehand bool ok = token.transferFrom(msg.sender, address(this), amount); require(ok, "transferFrom failed"); emit Taken(address(token), msg.sender, amount); } }
For production, prefer OpenZeppelin’s IERC20
and SafeERC20
wrappers to support non-standard tokens safely:
using SafeERC20 for IERC20;
token.safeTransferFrom(...)
11) Capstone: MiniCrowdfund with refunds
Let us build a small but realistic project. Creators open a campaign with a goal and deadline. Backers pledge ETH. If the goal is reached by the deadline, the creator withdraws funds. Otherwise, backers can refund themselves. We will use events, errors, modifiers, mappings, CEI, and the pull-pattern for refunds.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title MiniCrowdfund - goal-based crowdfunding with refunds /// @notice Educational example covering state, events, errors, modifiers, CEI, and pull refunds contract MiniCrowdfund { struct Campaign { address creator; uint96 goal; // in wei uint64 deadline; // unix seconds bool claimed; // creator withdrew uint96 pledged; // total pledged (fits 96-bit for gas) } // campaignId => Campaign mapping(uint256 => Campaign) public campaigns; // campaignId => backer => amount pledged mapping(uint256 => mapping(address => uint256)) public pledges; uint256 public nextId; address public immutable feeCollector; uint16 public feeBps; // e.g., 100 = 1 percent (educational) // Events event Launched(uint256 indexed id, address indexed creator, uint256 goal, uint256 deadline); event Pledged(uint256 indexed id, address indexed backer, uint256 amount); event Unpledged(uint256 indexed id, address indexed backer, uint256 amount); event Claimed(uint256 indexed id, address indexed creator, uint256 amount, uint256 fee); event Refunded(uint256 indexed id, address indexed backer, uint256 amount); event FeeUpdated(uint16 feeBps); // Errors error BadParams(); error NotCreator(); error NotEnded(); error NotLive(); error GoalNotMet(); error AlreadyClaimed(); error NothingToRefund(); error FeeTooHigh(); constructor(address _feeCollector, uint16 _feeBps) { require(_feeCollector != address(0), "feeCollector zero"); if (_feeBps > 1_000) revert FeeTooHigh(); // cap 10 percent for demo feeCollector = _feeCollector; feeBps = _feeBps; } modifier onlyCreator(uint256 id) { if (msg.sender != campaigns[id].creator) revert NotCreator(); _; } /// @notice Launch a new campaign /// @param goal amount needed (wei) /// @param duration seconds until deadline from now function launch(uint96 goal, uint64 duration) external returns (uint256 id) { if (goal == 0 || duration < 1 hours) revert BadParams(); id = nextId++; campaigns[id] = Campaign({ creator: msg.sender, goal: goal, deadline: uint64(block.timestamp) + duration, claimed: false, pledged: 0 }); emit Launched(id, msg.sender, goal, uint256(campaigns[id].deadline)); } /// @notice Pledge ETH to a campaign function pledge(uint256 id) external payable { Campaign storage c = campaigns[id]; if (c.creator == address(0)) revert BadParams(); // nonexistent if (block.timestamp >= c.deadline) revert NotLive(); if (msg.value == 0) revert BadParams(); c.pledged += uint96(msg.value); // Effects pledges[id][msg.sender] += msg.value; emit Pledged(id, msg.sender, msg.value); // Log // No external interactions here (CEI) } /// @notice Unpledge before the deadline (change your mind) function unpledge(uint256 id, uint256 amount) external { Campaign storage c = campaigns[id]; if (block.timestamp >= c.deadline) revert NotLive(); uint256 p = pledges[id][msg.sender]; require(amount > 0 && amount <= p, "bad amount"); pledges[id][msg.sender] = p - amount; // Effects c.pledged -= uint96(amount); (bool ok,) = msg.sender.call{value: amount}(""); // Interactions require(ok, "refund failed"); emit Unpledged(id, msg.sender, amount); } /// @notice Creator claims if goal met by deadline function claim(uint256 id) external onlyCreator(id) { Campaign storage c = campaigns[id]; if (block.timestamp < c.deadline) revert NotEnded(); if (c.pledged < c.goal) revert GoalNotMet(); if (c.claimed) revert AlreadyClaimed(); c.claimed = true; // Effects uint256 amount = c.pledged; uint256 fee = (amount * feeBps) / 10_000; uint256 toCreator = amount - fee; // Interactions: send to creator then fee collector (bool ok1,) = c.creator.call{value: toCreator}(""); (bool ok2,) = feeCollector.call{value: fee}(""); require(ok1 && ok2, "payout failed"); emit Claimed(id, c.creator, toCreator, fee); } /// @notice Backers refund if goal not met after deadline function refund(uint256 id) external { Campaign storage c = campaigns[id]; if (block.timestamp < c.deadline) revert NotEnded(); if (c.pledged >= c.goal) revert GoalNotMet(); uint256 p = pledges[id][msg.sender]; if (p == 0) revert NothingToRefund(); pledges[id][msg.sender] = 0; // Effects (bool ok,) = msg.sender.call{value: p}(""); // Interactions require(ok, "refund failed"); emit Refunded(id, msg.sender, p); } /// @notice Admin: adjust fee (timelock/multisig recommended in real life) function setFee(uint16 _feeBps) external { if (msg.sender != feeCollector) revert NotCreator(); // reuse error to keep example short if (_feeBps > 1_000) revert FeeTooHigh(); feeBps = _feeBps; emit FeeUpdated(_feeBps); } // View helpers function timeLeft(uint256 id) external view returns (int256) { Campaign storage c = campaigns[id]; return int256(uint256(c.deadline)) - int256(block.timestamp); } }
Try this flow in Remix
- Deploy with any
feeCollector
(your account is fine) andfeeBps = 100
(1 percent). - Alice calls
launch(goal=5 ether, duration=1 day)
→ note the returnedid
. - Bob and Carol call
pledge(id)
sending 3 and 2 ether. - Fast-forward time (in local chains) or wait until after deadline. Alice calls
claim(id)
and receives funds minus fee. - Repeat with a campaign that does not reach goal. After deadline, Bob calls
refund(id)
.
12) Quick testing notes (Remix, Foundry, or Hardhat)
Remix gives you a console and multiple accounts. Great for manual poking. But you should write tests as soon as possible:
- Foundry (Solidity tests): create
test/MiniCrowdfund.t.sol
. Usevm.warp()
to move time andvm.prank()
to simulate different callers. Runforge test -vv
. - Hardhat (JS/TS tests): write tests in
test/
using ethers.js. Usetime.increase()
to advance time on a local network.
Minimum viable tests (ideas):
- Launch records creator, goal, and deadline.
- Pledge accumulates totals and emits event.
- Unpledge before deadline returns ETH and updates mappings.
- Claim works only when goal met after deadline and only once.
- Refund path works only when goal not met after deadline.
13) Security checklist (common gotchas)
- Reentrancy: Always follow CEI. Consider
ReentrancyGuard
from OpenZeppelin for external entrypoints. - Randomness:
block.timestamp
andblockhash
are not secure sources of randomness. Use Chainlink VRF or commit-reveal schemes. - Access control: Never use
tx.origin
for auth. Usemsg.sender
, roles, and multisigs with timelocks for big levers. - External tokens: Some ERC20s are non-standard. Use
SafeERC20
. Beware of fee-on-transfer tokens when accounting amounts. - Upgradeability: If you are new, deploy non-upgradeable v1. Upgrades require strict storage layout discipline and extra testing.
- Decimals and units: ETH is 18 decimals. Tokens vary (6/8/18). Normalize before math and document units (wad = 1e18 convention).
- DoS via state growth or loops: Do not loop through unbounded arrays in user-triggered functions. Use mappings and pagination or pull patterns.
- Error handling: Prefer custom errors for gas and clarity. Bubble meaningful failures.
14) Gas tips for beginners
- Store rarely changed constants as
immutable
(set once) orconstant
(compile-time). - Use
calldata
for external function inputs (cheaper for big arrays/strings). - Emit events instead of writing redundant storage when you just need an audit trail.
- Prefer
uint256
on EVM (native 256-bit). Consider smaller types only when tightly packed in structs. - Avoid unnecessary
storage
reads/writes inside loops. Cache tomemory
variables when possible.
15) Further lectures and structured learning paths
Keep going with these well-regarded resources (all free unless noted):
- Solidity Official Docs — language reference with examples and changelog.
- OpenZeppelin Contracts — audited building blocks (Ownable, AccessControl, ERC20, ERC721, Pausable, ReentrancyGuard).
- Remix IDE — learn by doing in the browser.
- Foundry Book — fast testing and deployment with Solidity-based tests.
- Hardhat Docs — JS/TS toolchain for scripting, testing, and forking mainnet.
- Solidity by Example — concise patterns with code snippets.
- eth.build — visual, interactive EVM learning.
- evm.codes — opcode reference (nice to peek under the hood).
- Ethernaut and Damn Vulnerable DeFi — wargames to learn security.
- Secureum — security primers and quizzes.
- Slither and Echidna — static analysis and fuzzing tools.
- Cyfrin Updraft (video courses), and the “Smart Contract Programmer” YouTube channel — approachable walkthroughs.
Practice prompts (build and extend)
- Add
Pausable
from OpenZeppelin to MiniCrowdfund so the owner can pausepledge
during incidents. - Write a tip jar that splits incoming ETH among N recipients based on shares (use pull-payments for claims).
- Build a minimal ERC20-like token for test purposes with mint and burn restricted to the owner. Then, write a contract that accepts that token and lets users deposit and withdraw.
- Refactor MyAddressBook to support labels per address and add events for updates. Add pagination to avoid returning huge arrays.