Solidity Basics

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.

Heads-up: Educational content, not financial or security advice. Use test networks while learning. Real money raises real risk.

TL;DR: Solidity lets you write programs (contracts) that live on Ethereum and compatible chains. A contract is a little database (state) plus functions. Users call functions and pay gas. You will build:

  1. A “Hello, Blockchain” contract
  2. A simple ETH vault (deposit and withdraw)
  3. An address book with structs, arrays, and mappings
  4. 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).
Rule of thumb: Reads are free (off-chain). Writes cost gas (on-chain). Design with that in mind.

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, then forge init myproj.
  • Hardhat: popular toolchain with JS/TS, plugins, and scripts. npm i -D hardhat, then npx 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).

Concept check: Why does 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 for external 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.

Gas tip: Use 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 inside
  • external: callable by others, not internally unless you use this.fn(); cheaper for big inputs
  • internal: callable only from this contract or children
  • private: callable only from this exact contract

Mutability controls “write vs read”:

  • view: reads state; no writes
  • pure: 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;
    }
}
Safety: In Solidity ≥0.8, arithmetic reverts on overflow by default. That helps, but you still must order operations to avoid reentrancy and inconsistent state (see CEI next).

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);
    // ...
}
Use 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

  1. Deploy with any feeCollector (your account is fine) and feeBps = 100 (1 percent).
  2. Alice calls launch(goal=5 ether, duration=1 day) → note the returned id.
  3. Bob and Carol call pledge(id) sending 3 and 2 ether.
  4. Fast-forward time (in local chains) or wait until after deadline. Alice calls claim(id) and receives funds minus fee.
  5. Repeat with a campaign that does not reach goal. After deadline, Bob calls refund(id).
What you learned: dynamic data (mappings of mappings), time checks, error types, events, and the CEI order when moving ETH.

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. Use vm.warp() to move time and vm.prank() to simulate different callers. Run forge test -vv.
  • Hardhat (JS/TS tests): write tests in test/ using ethers.js. Use time.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 and blockhash are not secure sources of randomness. Use Chainlink VRF or commit-reveal schemes.
  • Access control: Never use tx.origin for auth. Use msg.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) or constant (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 to memory variables when possible.

15) Further lectures and structured learning paths

Keep going with these well-regarded resources (all free unless noted):

Practice prompts (build and extend)

  1. Add Pausable from OpenZeppelin to MiniCrowdfund so the owner can pause pledge during incidents.
  2. Write a tip jar that splits incoming ETH among N recipients based on shares (use pull-payments for claims).
  3. 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.
  4. Refactor MyAddressBook to support labels per address and add events for updates. Add pagination to avoid returning huge arrays.