Solidity, Ethereum smart contracts, Remix, Foundry, Hardhat, state variables, payable functions, events, custom errors, access control, CEI, reentrancy safety, interfaces, ERC-20 calls, testing, and beginner smart contract security

Solidity Basics: A Beginner's Guide to Ethereum Smart Contracts

Smart Contracts • ~28 min read • Updated: 08/08/2025

Solidity basics are the foundation for writing Ethereum smart contracts safely. Solidity lets developers create programs that live on Ethereum and EVM-compatible chains. These programs can hold ETH, store data, manage token balances, enforce rules, emit events, and interact with other contracts. This TokenToolHub guide starts from the beginner mental model, then moves into practical Solidity examples, testing habits, security patterns, gas tips, and a mini crowdfunding capstone that brings the concepts together.

TL;DR

  • Solidity is the main programming language used to write smart contracts on Ethereum and many EVM-compatible chains.
  • A smart contract is code plus state. The code defines functions, while the state stores values on-chain.
  • Reading public state is usually free from a user interface. Writing state requires a transaction and costs gas.
  • Beginners should start in Remix because it runs in the browser and lets you compile, deploy, and test contracts quickly.
  • Foundry and Hardhat are stronger local development toolchains for serious testing, scripting, and production workflows.
  • Core Solidity skills include state variables, types, mappings, arrays, structs, functions, visibility, mutability, payable functions, events, errors, modifiers, and interfaces.
  • The most important safety pattern for beginners is Checks, Effects, Interactions, often shortened as CEI.
  • Use pull payments, custom errors, access control, safe token libraries, and test networks before touching real funds.
  • This guide includes four practical builds: Hello Blockchain, MiniVault, MyAddressBook, and MiniCrowdfund.
  • Use Token Safety Checker, ERC-20 Wizard, and AI Learning Hub as part of your smart contract learning workflow.
Important safety note

Solidity, Ethereum smart contracts, test networks, deployments, token contracts, ETH vaults, crowdfunding contracts, ERC-20 interactions, interfaces, upgradeability, access control, payable functions, external calls, RPC infrastructure, smart contract testing, and wallet signing can involve bugs, failed transactions, reentrancy, bad accounting, malicious approvals, unsafe permissions, incorrect deployment parameters, wrong-chain activity, regulatory uncertainty, tax complexity, and total loss of funds. This guide is educational only and is not financial, investment, legal, tax, deployment, audit, or security advice.

The mental model: what actually happens on-chain

A smart contract is easiest to understand as a public database with functions attached. The database part is the contract state. The function part is the logic that controls how that state changes. When a contract is deployed, its code is stored on-chain. When a user calls a function that writes state, the transaction is executed by Ethereum nodes, the result is verified by the network, and the updated state becomes part of Ethereum history.

This is different from a normal web app. In a normal app, a company controls the server, database, permissions, and backend code. In a smart contract system, the contract code sits on a blockchain. Users interact with it by signing transactions from their wallets. The contract does not need a traditional backend to enforce its core rules. The blockchain executes those rules.

That does not mean smart contracts are automatically safe. A bad contract will faithfully execute bad logic. A contract with a bug can lock funds. A contract with weak access control can give the wrong person power. A contract that sends ETH before updating balances can expose itself to reentrancy. Solidity gives you powerful tools, but power requires discipline.

State, transactions, gas, and events

State is data stored on-chain inside a contract. A state variable might store an owner address, a user's balance, a crowdfunding goal, a mapping of pledges, or an array of contacts. State persists between transactions. If a contract writes to storage, the result remains available after the transaction completes.

Transactions are signed messages. When a wallet sends a transaction to a contract, it may call a function, send ETH, pass arguments, and consume gas. Gas is the fee paid for computation, storage writes, and transaction execution. Writing storage is expensive because the network must preserve that data.

Events are logs. They help frontends, explorers, analytics tools, and indexers understand what happened. Events do not change contract state, but they are extremely useful for building user interfaces and transaction histories.

Smart contract mental model A contract is state plus functions. Users call functions through signed transactions. Wallet signs transaction The user chooses a function, parameters, ETH value, gas settings, and chain. Contract function runs Solidity logic checks inputs, updates state, emits events, and may call other contracts. Ethereum updates state If execution succeeds, storage changes become part of the chain's history. Rule of thumb: reads are cheap off-chain, writes cost gas on-chain.

Your sandbox: Remix, Foundry, or Hardhat

The fastest way to start learning Solidity is Remix. Remix runs inside your browser, requires no install, and gives you a compiler, deployment panel, file explorer, transaction console, and local test environment. For absolute beginners, Remix is the best place to build confidence because you can paste code, compile it, deploy it to a JavaScript VM, and interact with it in minutes.

Once you understand the basics, move to a local development toolchain. Foundry is fast and developer-focused. It lets you write tests in Solidity, use cheatcodes, fuzz inputs, fork networks, and run deployments with strong command-line tooling. Hardhat is a JavaScript and TypeScript-friendly ecosystem with strong plugin support, scripts, local networks, and testing integrations.

Tool Best for Why beginners use it When to move deeper
Remix Browser-based learning No installation. Compile, deploy, and test simple contracts quickly. Move deeper when you need automated tests, scripts, packages, or team workflows.
Foundry Fast Solidity-native testing Strong for unit tests, fuzzing, local forks, and serious smart contract development. Use it when you want repeatable tests and production-style workflows.
Hardhat JavaScript and TypeScript workflows Good plugin ecosystem, deployment scripts, local networks, and frontend integrations. Use it when your team already works heavily with JS, TS, and ethers.js.

Start with Remix until you understand state variables, functions, visibility, events, errors, and payable functions. Then rebuild the same contracts in Foundry or Hardhat. This gives you two layers of understanding: manual interaction and automated testing. Manual interaction teaches what users see. Automated tests teach how contracts behave under many conditions.

Beginner Solidity environment path Step 1: Use Remix Compile contracts Deploy to JavaScript VM Call functions manually Step 2: Install Foundry or Hardhat Rebuild the same examples Write tests for normal and failure paths Step 3: Use testnets carefully Verify contracts Practice with small deployments only Step 4: Study audits, exploits, and secure patterns Never deploy production contracts without review

Warm-up: Hello Blockchain

The simplest useful Solidity contract stores a message and lets users update it. This is not financially useful, but it teaches deployment, constructor arguments, public state variables, external functions, calldata, and storage writes.

Paste the code below into Remix. Compile it with Solidity 0.8.24 or a compatible 0.8.x compiler. Deploy it with an initial message such as gm, world. Then call message() to read the current message. After that, call setMessage() to write a new message. Reading is free from the interface. Writing would cost gas on a real network.

// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title HelloBlockchain - minimal state and function example /// @notice Stores and updates a message contract HelloBlockchain { string public message; /// @dev Runs once at deployment constructor(string memory initialMessage) { message = initialMessage; } /// @notice Update the message. Anyone can call this in this beginner example. function setMessage(string calldata newMessage) external { message = newMessage; } }

What this contract teaches

The line string public message creates a state variable. Because it is public, Solidity automatically creates a getter function named message(). That getter lets users read the stored string without you writing a separate function.

The constructor runs once when the contract is deployed. It sets the first message. The setMessage function is external, which means it is designed to be called from outside the contract. The newMessage argument uses calldata because it is an external input and does not need to be modified inside the function.

This contract intentionally has no access control. Anyone can update the message. That is fine for a beginner demonstration, but not for a real application where only an owner, admin, DAO, or authorized user should control a value.

State, types, and data locations

Solidity has value types and reference types. Value types include uint256, bool, address, bytes32, int256, and enum values. They are copied by value. Reference types include arrays, strings, structs, mappings, and bytes. They can contain dynamic data and often require a data location.

Data locations are one of the first concepts beginners must understand. Solidity uses storage, memory, and calldata. Storage is persistent on-chain state. Memory is temporary data used during execution. Calldata is read-only input data for external calls. Choosing the correct location affects gas cost, safety, and function behavior.

Location Meaning Common use Beginner warning
storage Persistent on-chain state. State variables, mappings, arrays, and structs stored in the contract. Writing storage costs gas. A storage reference can modify contract state.
memory Temporary data during function execution. Return values, temporary structs, temporary arrays, internal calculations. Memory disappears after execution and does not persist on-chain.
calldata Read-only input data passed into external functions. External function parameters, especially strings and arrays. Cannot be modified. Often cheaper than copying large inputs to memory.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract TypesAndState { uint256 public count; bool public live = true; address public owner; string private note; bytes32 public configHash; uint256[] public numbers; mapping(address => uint256) public score; struct User { string name; uint64 joinedAt; uint256 balance; } mapping(address => User) public users; constructor() { owner = msg.sender; } function setNote(string calldata n) external { note = n; } function pushNumber(uint256 n) external { numbers.push(n); } 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) { return users[a]; } }

Mappings and why they do not have length

A mapping is like a hash table. It maps a key to a value. The key can be an address, uint256, bytes32, or other supported type. The value can be a number, struct, array, or another mapping. Mappings are extremely common in Solidity because they are efficient for balances, permissions, pledges, user profiles, ownership records, and lookup tables.

A mapping does not store a list of keys. It also has no length. That means you cannot iterate through a mapping by default. If you need enumeration, you must store keys separately in an array, emit events and index off-chain, or design a pagination system.

constant and immutable

Solidity supports constant and immutable variables. A constant value is known at compile time and does not use a normal storage slot. An immutable value is set once in the constructor and then cannot change. Beginners should use constant for values that are truly fixed and immutable for deployment-time configuration such as an owner, token address, fee collector, or trusted dependency.

Functions: visibility, mutability, and payable

Functions define what users and other contracts can do. Every function has visibility, and many functions also have mutability. Visibility answers who can call the function. Mutability answers whether the function reads state, writes state, receives ETH, or performs pure computation.

Keyword Category Meaning Example use
public Visibility Callable from outside and inside the contract. Simple user-facing functions and public getters.
external Visibility Callable from outside the contract. Internal calls require this.functionName(). User entrypoints with calldata inputs.
internal Visibility Callable only by this contract and child contracts. Shared helper logic in inheritance-based designs.
private Visibility Callable only from this exact contract. Local helper functions that should not be inherited.
view Mutability Reads state but does not write state. Balance checks, status views, getters.
pure Mutability Does not read or write state. Math helpers and deterministic calculations.
payable ETH handling Allows the function to receive ETH. Deposits, mints, payments, and vault interactions.

Payable functions

A function must be payable to receive ETH directly through msg.value. If a user sends ETH to a non-payable function, the transaction reverts. Contracts that accept raw ETH may also define receive() and fallback() functions. The receive function handles plain ETH transfers with empty calldata. The fallback function can handle unknown function selectors or calls where no other function matches.

// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract PayableExample { mapping(address => uint256) public balanceOf; function deposit() external payable { require(msg.value > 0, "send ETH"); balanceOf[msg.sender] += msg.value; } function withdraw(uint256 amount) external { uint256 bal = balanceOf[msg.sender]; require(amount <= bal, "not enough"); balanceOf[msg.sender] = bal - amount; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); } function contractBalance() external view returns (uint256) { return address(this).balance; } receive() external payable { balanceOf[msg.sender] += msg.value; } }

Payable safety

The withdraw function above follows an important pattern. It checks the user balance, updates the contract's internal accounting, then sends ETH. This order matters. If a contract sends ETH before updating internal balances, a malicious receiving contract may be able to reenter and withdraw multiple times.

Solidity 0.8 and later automatically reverts on arithmetic overflow and underflow. That is helpful, but it does not replace careful security design. Reentrancy, access control mistakes, unsafe external calls, oracle issues, rounding, and accounting bugs remain serious risks.

Events and custom errors

Events are how smart contracts communicate with off-chain systems. A frontend can listen for events. An indexer can build a database from events. A block explorer can show users what happened. Events are not state, but they are one of the most important parts of a good user experience.

A deposit function should emit a Deposited event. A withdraw function should emit a Withdrawn event. A campaign launch should emit a Launched event. An ownership transfer should emit an OwnershipTransferred event. Without events, users and tools can still inspect transactions, but the experience is weaker.

event Deposited(address indexed user, uint256 amount); event Withdrawn(address indexed user, uint256 amount); error InsufficientBalance(uint256 requested, uint256 available); error ZeroAmount(); function deposit() external payable { if (msg.value == 0) revert ZeroAmount(); // update state here emit Deposited(msg.sender, msg.value); } function withdraw(uint256 amount) external { uint256 bal = balanceOf[msg.sender]; if (amount > bal) { revert InsufficientBalance(amount, bal); } // update state and transfer ETH here emit Withdrawn(msg.sender, amount); }

Why custom errors matter

Custom errors are more gas-efficient than long revert strings and more structured for debugging. A custom error can include parameters that explain what went wrong. For example, InsufficientBalance can show the requested amount and the available amount.

For quick prototypes, require statements with short strings are acceptable. For production-grade code, custom errors improve gas efficiency, consistency, and clarity. They also make it easier for tools and tests to check exact failure conditions.

Modifiers and access control

Modifiers let you attach reusable checks to functions. The classic example is onlyOwner. If a function should only be called by the owner, a modifier can check msg.sender before the function body runs.

Access control is one of the most important security topics in Solidity. A contract may need admin functions to pause, update parameters, withdraw fees, change a treasury address, or manage roles. If those functions are public without proper checks, anyone may be able to take over the system.

// 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 } }

Use audited libraries for real projects

For real applications, beginners should avoid writing access control from scratch unless the goal is education. OpenZeppelin provides widely used implementations such as Ownable, AccessControl, Pausable, ReentrancyGuard, ERC20, ERC721, and SafeERC20. These libraries are not a substitute for audits, but they help developers avoid obvious mistakes.

If a contract controls meaningful value, use multisigs, timelocks, clear admin separation, event logging, and documented emergency procedures. Admin power should never be vague. Users should know who can pause, upgrade, withdraw, or change core settings.

Core safety patterns: CEI, pull payments, and try/catch

Solidity safety starts with patterns. The most important beginner pattern is Checks, Effects, Interactions. First validate inputs and permissions. Then update your own state. Then call external contracts or send ETH. This order reduces reentrancy risk because internal accounting is already updated before control leaves your contract.

CEI: Checks, Effects, Interactions

function withdraw(uint256 amount) external { uint256 bal = balanceOf[msg.sender]; // Checks require(amount > 0, "zero amount"); require(amount <= bal, "not enough balance"); // Effects balanceOf[msg.sender] = bal - amount; // Interactions (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); }

Pull payments over push payments

Push payments send ETH or tokens to users during a function. Pull payments record what users can claim, then let users withdraw in a separate call. Pull payments reduce the risk that a failed external transfer breaks the entire flow. They also reduce complexity when multiple users need to receive funds.

mapping(address => uint256) public pending; function credit(address user, uint256 amount) internal { pending[user] += amount; } function claim() external { uint256 amount = pending[msg.sender]; require(amount > 0, "nothing to claim"); pending[msg.sender] = 0; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "claim failed"); }

try/catch for external calls

Solidity supports try/catch for external calls and contract creation. It lets a contract handle a failure gracefully instead of reverting everything. This can be useful when a contract calls another contract that may fail, but it should be used carefully. Sometimes a revert should stop the entire transaction. Sometimes a failure can be recorded and handled later.

Project 1: Minimal ETH Vault

The MiniVault contract teaches payable deposits, internal balances, custom errors, events, CEI, and ETH withdrawals. It is still educational code, not a production vault. Production vaults require stronger testing, reentrancy protection, accounting review, edge-case handling, audits, and careful design.

// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title Minimal ETH Vault /// @notice Educational deposit and withdraw example 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; emit Deposited(msg.sender, msg.value); } function withdraw(uint256 amount) external { uint256 bal = _bal[msg.sender]; if (amount > bal) revert Insufficient(amount, bal); _bal[msg.sender] = bal - amount; (bool ok, ) = msg.sender.call{value: amount}(""); 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 this in Remix

  1. Deploy MiniVault inside Remix using the JavaScript VM.
  2. Deposit 1 ether from one test account.
  3. Call balanceOf with that account address.
  4. Withdraw 0.3 ether.
  5. Check balanceOf again.
  6. Look at the event logs for Deposited and Withdrawn.

This project teaches the minimum viable pattern for holding ETH in a contract. The key lesson is not the vault itself. The key lesson is the accounting order: accept ETH, update balances, emit events, and withdraw using Checks, Effects, Interactions.

Project 2: AddressBook with structs, arrays, and mappings

The AddressBook project teaches how to combine structs, dynamic arrays, and mappings. Each user has their own list of contacts. A contact contains a name, tag, wallet address, and timestamp. Users can add, update, remove, list, and count contacts.

This is a good beginner project because it feels like a normal app, but it exposes important smart contract design questions. Should contacts be stored on-chain? How much gas does storage cost? What happens if arrays grow too large? How should a frontend handle pagination? Should private personal data ever be stored publicly? These questions matter because blockchains are public databases, not private cloud servers.

// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title MyAddressBook - demonstrates structs, arrays, and mappings contract MyAddressBook { struct Contact { string name; string tag; address wallet; uint64 addedAt; } 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]; 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(); 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; } }

Why swap-and-pop is used

The removeContact function uses swap-and-pop. Instead of shifting every later item down by one position, it copies the last element into the removed index and pops the last slot. This is cheaper than shifting large arrays. The tradeoff is that it does not preserve order. If ordering matters, you need a different approach and must accept higher gas cost or design around pagination.

Interfaces and calling other contracts

Most useful smart contracts do not live alone. They interact with tokens, routers, vaults, lending protocols, price feeds, staking contracts, NFTs, and governance systems. An interface lets your contract describe the external functions it expects to call.

For example, an ERC-20 token has functions such as transfer, transferFrom, approve, allowance, balanceOf, and decimals. Your contract does not need the full token source code to call transferFrom. It only needs an interface that declares the function signature.

// 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 { bool ok = token.transferFrom(msg.sender, address(this), amount); require(ok, "transferFrom failed"); emit Taken(address(token), msg.sender, amount); } }

Why SafeERC20 matters

Not all tokens behave perfectly. Some ERC-20 tokens return false. Some return nothing. Some charge transfer fees. Some have blacklists or pause mechanics. Some are malicious. For production interactions, developers usually prefer OpenZeppelin's IERC20 and SafeERC20. SafeERC20 wraps token calls and handles common non-standard behavior more safely.

Production token interaction pattern Use: OpenZeppelin IERC20 OpenZeppelin SafeERC20 Example idea: using SafeERC20 for IERC20; token.safeTransferFrom(user, address(this), amount); Why: supports common non-standard ERC-20 behavior reduces fragile assumptions improves compatibility

Supporting resources for Solidity development

Successful smart contract development depends on strong fundamentals, careful testing, security awareness, deployment discipline, and continuous learning. Understanding how contracts behave before and after deployment is often more important than simply learning Solidity syntax. The resources below support those broader development and research workflows.

Capstone: MiniCrowdfund with refunds

The MiniCrowdfund project combines many beginner Solidity concepts into one small but realistic contract. A creator launches a campaign with a funding goal and deadline. Backers pledge ETH. If the goal is reached by the deadline, the creator can claim funds. If the goal is not reached, backers can refund themselves.

This project teaches structs, mappings, mappings inside mappings, events, errors, modifiers, payable functions, time checks, fee calculation, withdrawals, refunds, and CEI. It also teaches why real contracts need careful review. Even a small crowdfunding contract has many edge cases: deadlines, refunds, double claims, fee limits, failed transfers, campaign existence checks, and permissions.

// 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 refunds contract MiniCrowdfund { struct Campaign { address creator; uint96 goal; uint64 deadline; bool claimed; uint96 pledged; } mapping(uint256 => Campaign) public campaigns; mapping(uint256 => mapping(address => uint256)) public pledges; uint256 public nextId; address public immutable feeCollector; uint16 public feeBps; 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); 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(); feeCollector = _feeCollector; feeBps = _feeBps; } modifier onlyCreator(uint256 id) { if (msg.sender != campaigns[id].creator) revert NotCreator(); _; } 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)); } function pledge(uint256 id) external payable { Campaign storage c = campaigns[id]; if (c.creator == address(0)) revert BadParams(); if (block.timestamp >= c.deadline) revert NotLive(); if (msg.value == 0) revert BadParams(); c.pledged += uint96(msg.value); pledges[id][msg.sender] += msg.value; emit Pledged(id, msg.sender, msg.value); } 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; c.pledged -= uint96(amount); (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "refund failed"); emit Unpledged(id, msg.sender, amount); } 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; uint256 amount = c.pledged; uint256 fee = (amount * feeBps) / 10_000; uint256 toCreator = amount - fee; (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); } 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; (bool ok, ) = msg.sender.call{value: p}(""); require(ok, "refund failed"); emit Refunded(id, msg.sender, p); } function setFee(uint16 _feeBps) external { if (msg.sender != feeCollector) revert NotCreator(); if (_feeBps > 1_000) revert FeeTooHigh(); feeBps = _feeBps; emit FeeUpdated(_feeBps); } 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 MiniCrowdfund with your current account as feeCollector and feeBps set to 100.
  2. Launch a campaign with goal set to 5 ether and duration set to 1 day.
  3. Use different Remix accounts to pledge 3 ether and 2 ether.
  4. After the deadline, the creator calls claim.
  5. Repeat with a campaign that fails to reach the goal.
  6. After the deadline, backers call refund to retrieve their pledged ETH.

What the capstone teaches

The capstone teaches that real smart contracts are state machines. A campaign moves through phases: launched, live, ended, goal met, goal missed, claimed, or refunded. Functions should only work in the correct phase. That is why the contract checks timestamps, pledge totals, claim status, and caller identity.

It also teaches the limits of beginner examples. The MiniCrowdfund contract is useful for learning, but a production crowdfunding system would need more review. It may need reentrancy guards, multisig-controlled fees, pausing, better campaign existence handling, fee withdrawal separation, safer accounting around uint96 casts, frontend validation, tests, formal review, and external audit.

Quick testing notes: Remix, Foundry, and Hardhat

Manual testing is not enough. Remix is good for exploration, but automated tests are how developers catch edge cases repeatedly. A contract should be tested for successful flows and failure flows. It should test who can call privileged functions, what happens before and after deadlines, how balances change, which events emit, and whether invalid inputs revert.

Foundry lets you write tests in Solidity. This is powerful because the tests feel close to the contract language itself. Foundry cheatcodes such as vm.prank and vm.warp let you simulate different callers and time movement. Hardhat lets you write tests in JavaScript or TypeScript using ethers.js and local network helpers.

Minimum viable tests for MiniCrowdfund

  • Launching records creator, goal, deadline, and campaign ID.
  • Pledging increases campaign pledged amount and user pledge amount.
  • Pledging zero ETH reverts.
  • Unpledging before deadline returns ETH and updates mappings.
  • Unpledging after deadline reverts.
  • Claim works only after deadline and only when the goal is met.
  • Claim can only be called once.
  • Refund works only after deadline when the goal is not met.
  • Refund cannot be claimed twice.
  • Fee changes respect the fee cap.

Security checklist for beginner Solidity developers

Solidity security is not one trick. It is a habit. You need safe patterns, tests, reviews, and humility. Most smart contract losses do not happen because the language is impossible. They happen because developers underestimate edge cases, external calls, permissions, accounting, upgradeability, or user behavior.

Risk area What can go wrong Safer habit
Reentrancy A malicious contract calls back before state is updated. Use CEI and consider OpenZeppelin ReentrancyGuard for external entrypoints.
Access control Anyone can call privileged functions. Use onlyOwner, roles, multisigs, timelocks, and clear admin boundaries.
Randomness block.timestamp or blockhash is manipulated or predictable. Use commit-reveal, VRF, or carefully designed randomness systems.
External tokens Non-standard ERC-20 behavior breaks accounting. Use SafeERC20 and test fee-on-transfer and non-standard tokens carefully.
Loops Unbounded loops run out of gas and create denial of service. Use mappings, pagination, pull patterns, and bounded loops.
Upgradeability Storage layout mistakes or admin abuse breaks user trust. Start non-upgradeable when learning. Use strict review for upgradeable systems.
Decimals and units Wrong assumptions about 6, 8, or 18 decimals break amounts. Document units, normalize carefully, and test with different token decimals.
tx.origin Phishing-style call chains can bypass weak authorization. Use msg.sender for authorization, not tx.origin.

Common beginner gotchas

  • Forgetting that all on-chain storage is public, even if a variable is marked private.
  • Using private as if it means secret. It only restricts Solidity-level access, not chain visibility.
  • Assuming events can be read by contracts. Events are for off-chain indexing, not internal contract logic.
  • Looping over arrays that can grow forever.
  • Sending ETH before updating balances.
  • Forgetting to test revert paths.
  • Deploying to mainnet before using test networks.
  • Approving or calling unknown contracts from a wallet holding meaningful funds.

Gas tips for beginners

Gas optimization matters, but beginners should not optimize before understanding correctness. A cheap broken contract is still broken. First write clear and safe code. Then learn where gas is actually spent. Storage writes are expensive. Dynamic arrays can become expensive. External calls need care. Events are usually cheaper than redundant storage when you only need an audit trail.

Beginner gas habits

  • Use calldata for external function inputs when you do not need to modify the data.
  • Use constant for values known at compile time.
  • Use immutable for values set once in the constructor.
  • Avoid unnecessary storage writes.
  • Cache repeated storage reads inside a function when practical.
  • Emit events instead of storing redundant history when off-chain indexing is enough.
  • Avoid unbounded loops in user-triggered functions.
  • Prefer clear safe code before micro-optimization.

A practical Solidity developer workflow

A strong Solidity workflow is predictable. Write a small contract. Compile often. Test manually in Remix. Move to Foundry or Hardhat. Write unit tests. Add failure-path tests. Run static analysis. Review access control. Review external calls. Review events. Review storage writes. Deploy to a testnet. Verify the contract. Interact with it from a clean wallet. Only then consider mainnet or production use.

Solidity beginner workflow Plan: define the contract purpose list who can call each function list what state changes each function makes list what external calls happen Build: write small contracts compile often use custom errors emit useful events Test: test success paths test revert paths test different callers test deadline and time logic test zero values and edge cases Review: check access control check CEI check external calls check token handling check loops and storage growth Deploy: use test networks first verify contract source document constructor arguments monitor transactions and events

TokenToolHub workflow for learning Solidity safely

TokenToolHub's Solidity learning workflow is simple: learn the syntax, build small examples, test repeatedly, inspect real transactions, and never treat beginner code as production code. Solidity is practical, but every production contract needs a higher standard than a tutorial.

Beginners should pair coding practice with transaction literacy. When you deploy a contract, inspect the deployment transaction. When you call a function, inspect the calldata. When your contract emits events, inspect the logs. When a call fails, inspect the revert reason. This makes you a stronger developer because you understand both Solidity source code and what actually happens on-chain.

TokenToolHub Solidity learning workflow Learn: read Solidity syntax understand state, functions, events, and errors understand gas and transactions Build: HelloBlockchain MiniVault MyAddressBook MiniCrowdfund Inspect: deployment transaction function calldata emitted logs revert reasons gas used Secure: follow CEI avoid unbounded loops protect admin functions use SafeERC20 for token calls test failure paths Grow: move from Remix to Foundry or Hardhat study OpenZeppelin practice with Ethernaut and security wargames read real audits and postmortems

Practice prompts and extension projects

Solidity improves through repetition. After building the examples in this guide, extend them. Change one feature at a time. Add tests. Break your own assumptions. Try to make the contract fail. Then improve it.

Practice projects

  • Add a pause mechanism to MiniCrowdfund so pledging can be stopped during emergencies.
  • Refactor MiniVault to use OpenZeppelin ReentrancyGuard.
  • Add pagination to MyAddressBook so users do not return a huge array at once.
  • Build a tip jar that splits incoming ETH across multiple recipients using pull payments.
  • Create a minimal ERC-20 test token with owner-only minting and user burning.
  • Build a TokenSink that accepts an ERC-20 token with SafeERC20.
  • Write tests for failed withdrawals, failed refunds, invalid campaign IDs, and repeated claims.
  • Deploy HelloBlockchain to a testnet and verify the contract source.

TokenToolHub tool stack

Learning Solidity involves more than writing code that compiles. You need a learning environment, a testing framework, a contract library, a scanner, a reliable RPC provider, and safe signing habits. Avoid tool overload at the start. Learn the core workflow first, then add more specialized tools as your contracts become more serious.

Need Tool or resource Why it matters
Browser learning Remix IDE Fastest way to compile, deploy, and test beginner Solidity contracts without installing anything.
Solidity testing Foundry Book Strong Solidity-native testing workflow with fast tests, cheatcodes, fuzzing, and local forks.
JS or TS testing Hardhat Docs Useful for JavaScript and TypeScript-based smart contract testing, scripting, and frontend integration.
Reusable contracts OpenZeppelin Contracts Provides widely used libraries for ownership, roles, pausing, reentrancy protection, tokens, and safe ERC-20 handling.
Contract safety Token Safety Checker Helps users inspect token and contract risk before interacting with unknown contracts.
Contract generation TokenToolHub ERC-20 Wizard Useful for learning token parameters, ERC-20 structure, and beginner token deployment concepts.
Wallet custody Ledger Helps isolate private keys when signing deployments and contract transactions from long-term wallets.
RPC infrastructure Chainstack Useful for builders who need reliable RPC, node access, transaction reads, and multi-chain infrastructure.
Developer RPC QuickNode Useful for dApps, testing, transaction monitoring, websocket use cases, and EVM developer workflows.
Multi-chain RPC access GetBlock Useful for developers exploring RPC access across multiple blockchain networks and test environments.

Quick check

Use these questions to confirm you understand Solidity basics beyond copy-and-paste examples.

  • What is the difference between a smart contract's code and its state?
  • Why does writing state cost gas?
  • What is the difference between storage, memory, and calldata?
  • Why does a public state variable create a getter?
  • What does payable allow a function to do?
  • Why are events useful for frontends and indexers?
  • Why are custom errors often better than long require strings?
  • What does CEI stand for?
  • Why are pull payments often safer than push payments?
  • Why should beginners avoid unbounded loops in user-triggered functions?
  • Why should production token interactions use SafeERC20?
  • Why is tx.origin unsafe for authorization?
Show answers

Contract code defines the rules, while state stores persistent on-chain data. Writing state costs gas because the network must execute and preserve the change. storage is persistent, memory is temporary, and calldata is read-only external input. A public state variable gets an automatic read-only getter. payable allows a function to receive ETH. Events help frontends and indexers track contract activity. Custom errors save gas and make failures structured. CEI means Checks, Effects, Interactions. Pull payments reduce external call risk. Unbounded loops can run out of gas. SafeERC20 handles common token behavior safely. tx.origin is unsafe because phishing-style call chains can abuse it.

Final verdict

Solidity basics are not only syntax. They are a way of thinking about public state, transactions, gas, permissions, and irreversible execution. A smart contract is a small program that can hold value and enforce rules without a traditional backend. That makes it powerful, but it also makes mistakes expensive.

Beginners should start with the mental model: state lives on-chain, functions change state, transactions call functions, gas pays for execution, and events help off-chain systems understand what happened. Once that model is clear, Solidity syntax becomes easier. State variables, mappings, structs, arrays, visibility, mutability, payable functions, events, errors, and modifiers all fit into the same architecture.

Remix is the fastest beginner environment. Use it to build HelloBlockchain, MiniVault, and MyAddressBook. Then move to Foundry or Hardhat for automated tests. Testing is not optional if a contract will hold funds or control permissions. Every meaningful contract needs success-path tests, failure-path tests, access-control tests, time tests, and edge-case tests.

Security should enter your learning path early. Learn CEI before writing withdrawal functions. Learn pull payments before building payout systems. Learn custom errors before writing production logic. Learn SafeERC20 before handling tokens. Learn why tx.origin is dangerous before writing access control. Learn why storage is public before storing anything sensitive.

The capstone MiniCrowdfund project shows how quickly simple ideas become real smart contract design problems. A crowdfunding contract needs goals, deadlines, pledges, refunds, claims, fee rules, events, permissions, and edge-case handling. That is the real lesson: smart contracts are small state machines with financial consequences.

The practical conclusion is clear. Start small. Use test networks. Read official docs. Use proven libraries. Write tests. Inspect transactions. Avoid mainnet experiments with real funds until your process is strong. Solidity rewards careful builders, but it punishes rushed assumptions.

Keep learning Solidity with safer workflows

Build small contracts, test every important path, inspect transactions, use trusted libraries, and treat every contract that touches funds as security-critical.

Frequently Asked Questions

What is Solidity?

Solidity is a programming language used to write smart contracts for Ethereum and many EVM-compatible chains. It lets developers define contract state, functions, events, errors, permissions, and interactions with other contracts.

Is Solidity hard for beginners?

Solidity is approachable for beginners if they start with small contracts and learn the blockchain mental model first. The difficult part is not only syntax. The difficult part is writing safe code that handles value, permissions, gas, external calls, and edge cases correctly.

What is the best place to start writing Solidity?

Remix is the easiest starting point because it runs in the browser and requires no installation. Beginners can compile, deploy, and test small contracts inside the Remix JavaScript VM before moving to Foundry or Hardhat.

What is the difference between storage, memory, and calldata?

storage is persistent on-chain data, memory is temporary data used during execution, and calldata is read-only input data for external functions. Choosing the correct data location affects gas cost and behavior.

What does payable mean in Solidity?

payable allows a function or address to receive ETH. A function that receives ETH through msg.value must be marked payable, otherwise the transaction will revert.

What is CEI in Solidity?

CEI means Checks, Effects, Interactions. It is a safety pattern where a function first validates inputs, then updates internal state, then makes external calls. This reduces reentrancy risk.

Should beginners use OpenZeppelin?

Yes, beginners should study OpenZeppelin and use its audited building blocks for real projects. OpenZeppelin contracts help with ownership, roles, pausing, reentrancy protection, tokens, and safe ERC-20 interactions.

Can I deploy beginner Solidity code to mainnet?

You can technically deploy any valid contract, but beginner code should not be used with real funds. Use local environments and test networks first. Production contracts need strong testing, review, and usually an audit.

Why should I avoid tx.origin for access control?

tx.origin can be abused through phishing-style call chains. Use msg.sender for authorization because it identifies the immediate caller of the function.

Do private variables hide data on Ethereum?

No. private only restricts access from other Solidity contracts. On-chain data is still visible to anyone who reads contract storage directly. Never store secrets in smart contract storage.

Glossary

Key terms

  • Solidity: a programming language for Ethereum and EVM smart contracts.
  • Smart contract: on-chain code that stores state and executes functions.
  • State: persistent data stored by a contract on-chain.
  • Transaction: a signed instruction that can call a contract and change state.
  • Gas: the fee unit used to pay for computation and storage on Ethereum.
  • storage: persistent on-chain contract data.
  • memory: temporary execution data that disappears after a function call.
  • calldata: read-only function input data for external calls.
  • Mapping: a key-value data structure commonly used for balances and permissions.
  • Struct: a custom data type grouping multiple fields.
  • Event: a log emitted by a contract for off-chain indexing and interfaces.
  • Custom error: a gas-efficient structured revert type.
  • Modifier: reusable function wrapper, often used for access checks.
  • payable: keyword that allows a function or address to receive ETH.
  • CEI: Checks, Effects, Interactions, a safety pattern for external calls.
  • Reentrancy: a vulnerability where an external call reenters before state is safely updated.
  • Interface: a declaration of external functions a contract can call.
  • SafeERC20: OpenZeppelin library that helps safely interact with ERC-20 tokens.

Further learning and resources

Use official docs, trusted libraries, security exercises, and TokenToolHub resources to continue learning:


This guide is general education only and is not financial, investment, legal, tax, deployment, audit, or security advice. Solidity contracts, Ethereum transactions, EVM deployments, token interactions, payable functions, external calls, RPC infrastructure, wallets, testnets, mainnet deployments, scanners, and development tools can involve bugs, failed transactions, reentrancy, unsafe permissions, wrong-chain activity, malicious approvals, regulatory uncertainty, tax complexity, and total loss of funds. Always test carefully, verify official documentation, protect private keys, review permissions, and use qualified security review before deploying contracts that control real value.

About the author: Wisdom Uche Ijika Verified icon 1
Founder @TokenToolHub | Web3 Technical Researcher, Token Security & On-Chain Intelligence | Helping traders and investors identify smart contract risks before interacting with tokens
Reader Supported Research

Support Independent Web3 Research

TokenToolHub publishes free Web3 security guides, smart contract risk explainers, and on-chain research resources for traders, builders, and investors. If this article helped you, you can optionally support the platform and help keep these resources free.

Network USDC on Base
Optional
0xBFCD4b0F3c307D235E540A9116A9f38cE65E666A

Support is completely optional. Please only send USDC on the Base network to this address. TokenToolHub will continue publishing free educational resources for the Web3 community.