Solidity Basics: Contracts, State, and Functions
1. Overview
Solidity is the primary language for writing smart contracts on Ethereum and EVM-compatible chains. This guide helps you grasp the three most fundamental building blocks: contract, state variable, and function — through a simple Wallet contract example. After reading, you’ll be able to write contracts that maintain state, control access using modifiers, and write your first tests with Hardhat.
2. What is a Contract
In Solidity, a contract is like a class in Java — it defines state (data) and behavior (functions) that will be deployed on the blockchain as an independent address.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Wallet {
// state variables and functions go here
}
- The
// SPDX-License-Identifierline declares the license; the compiler will warn if it’s missing. pragma solidity ^0.8.24locks the compiler version to 0.8.24 or higher (but below 0.9.0).- Each deployed contract gets its own unique address, like an “object” that lives permanently on the chain.
Note: Never use overly loose pragma like
>=0.8.0 <0.9.0for production — newer compilers may change default behavior (e.g., overflow checking).
3. State Variables — On-Chain Storage
State variables are data stored directly in the blockchain’s storage, persisting between function calls and consuming gas when written.
contract Wallet {
address public owner;
uint256 public balance;
mapping(address => uint256) public allowance;
constructor() {
owner = msg.sender;
}
}
address public owner: stores the contract owner’s address. Thepublickeyword automatically generates a getter functionowner().uint256 public balance: the balance, an unsigned 256-bit integer — the most common numeric type in Solidity.mapping(address => uint256): similar toHashMap<Address, BigInteger>but you cannot iterate keys; only look up by key.constructor()runs exactly once at deployment;msg.senderis the address that called the deployment transaction.
3.1. Storage, Memory, and Calldata
function updateAllowance(address spender, uint256 amount) external {
// amount is in calldata (read-only, cheap gas)
allowance[spender] = amount; // write to storage
}
storage: data persists permanently on the chain (state variables).memory: temporary data during function execution, lost when the function ends.calldata: similar tomemorybut read-only, used for parameters ofexternalfunctions — more gas-efficient thanmemory.
Note: Forgetting to declare
memory/calldataforarray,struct, orstringparameters will cause a compiler error — unlike Java, where you don’t need to worry about memory regions.
4. Functions and Visibility
Functions in Solidity require explicit visibility declaration; there is no default “package-private” like in Java.
contract Wallet {
address public owner;
uint256 public balance;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
balance += msg.value;
}
function withdraw(uint256 amount) external {
require(msg.sender == owner, "Wallet: not owner");
require(amount <= balance, "Wallet: insufficient balance");
balance -= amount;
(bool success, ) = owner.call{value: amount}("");
require(success, "Wallet: transfer failed");
}
function getBalance() external view returns (uint256) {
return balance;
}
}
external: can only be called from outside the contract (e.g., MetaMask, other contracts) — cheaper gas thanpublicwhen parameters are large arrays/strings.payable: allows the function to receive ETH with the transaction;msg.valueis the amount of wei sent.view: guarantees no state changes, costs no gas when called off-chain (viaeth_call).require(condition, "message"): checks a condition, reverts the entire transaction with a message if false — similar to throwing anIllegalArgumentException, but rolls back both state and spent gas (except gas for the check itself).owner.call{value: amount}(""): the recommended way to send ETH instead oftransfer()/send()because it doesn’t have a hard 2300 gas limit.
Four visibility levels:
| Visibility | Called from within contract | Called from child contract | Called from outside |
|---|---|---|---|
private |
✅ | ❌ | ❌ |
internal |
✅ | ✅ | ❌ |
public |
✅ | ✅ | ✅ |
external |
❌ (must use this.foo()) |
❌ | ✅ |
5. Modifiers — Reusing Access Checks
Repeating require(msg.sender == owner, ...) across multiple functions is a sign you should extract it into a modifier.
contract Wallet {
address public owner;
uint256 public balance;
modifier onlyOwner() {
require(msg.sender == owner, "Wallet: not owner");
_;
}
constructor() {
owner = msg.sender;
}
function withdraw(uint256 amount) external onlyOwner {
require(amount <= balance, "Wallet: insufficient balance");
balance -= amount;
payable(owner).transfer(amount);
}
}
modifier onlyOwner()defines a check block;_;is where the original function body is inserted.- Attaching
onlyOwnerto thewithdrawdeclaration makes the code read like “only the wallet owner can withdraw” — clearer than repeatingrequirein every function. - Modifiers can accept parameters, e.g.,
modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }.
6. Events — Logging for Off-Chain
Contracts cannot return historical data directly to the frontend; event is used to emit logs that dApps can listen to.
contract Wallet {
event Deposited(address indexed from, uint256 amount);
event Withdrawn(address indexed to, uint256 amount);
uint256 public balance;
function deposit() external payable {
balance += msg.value;
emit Deposited(msg.sender, msg.value);
}
}
event Deposited(...)declares the log structure; up to 3 parameters can beindexedfor easier filtering.emit Deposited(...)writes the log to the transaction receipt, costing much less gas than storing in state.- Frontend libraries like ethers.js can listen with
contract.on("Deposited", ...)to update the UI in real-time.
7. Common Mistakes When Learning Solidity
- Confusing
transfer/send/callfor sending ETH:transferis limited to 2300 gas and easily fails with receiving contracts that have complex logic (proxies, multisigs). Usecallwithrequireto check the result, as shown in section 4. - Forgetting
requireto validate input: missing validation leaves your contract vulnerable to exploits (e.g., negative withdrawals, overflow — even though Solidity 0.8+ auto-reverts on overflow, business logic still needs separate validation). - Using
externalvs.publicin the wrong context: usingpublicfor functions called only from outside wastes gas when parameters are arrays/strings, because Solidity must copy data intomemoryinstead of reading directly fromcalldata. - Not declaring
view/purewhen possible: this prevents tools like block explorers and wallets from recognizing read-only functions, and users mistakenly think they must pay gas.
When NOT to use
publicfunctions: if a function is never called from another contract or inherited, useexternalinstead to save gas and reduce attack surface.
8. Writing Tests with Hardhat
Add dependencies to your Hardhat project (Node.js):
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
Test the behavior that only the owner can withdraw:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Wallet", function () {
it("only owner can withdraw", async function () {
const [owner, stranger] = await ethers.getSigners();
const Wallet = await ethers.getContractFactory("Wallet");
const wallet = await Wallet.deploy();
await wallet.connect(owner).deposit({ value: ethers.parseEther("1") });
await expect(
wallet.connect(stranger).withdraw(ethers.parseEther("1"))
).to.be.revertedWith("Wallet: not owner");
await expect(
wallet.connect(owner).withdraw(ethers.parseEther("1"))
).to.changeEtherBalance(owner, ethers.parseEther("1"));
});
});
ethers.getContractFactory("Wallet")compiles and loads the contract artifact.wallet.connect(stranger)simulates a transaction sent from a different address than the owner.revertedWith("...")asserts that the transaction reverted with the exact message fromrequire.changeEtherBalancechecks that the owner’s ETH balance increased by exactly 1 ETH after withdrawal — a common testing pattern instead of just checking return values.
Run tests:
npx hardhat test
Wallet
✔ only owner can withdraw (120ms)
1 passing (120ms)
9. Conclusion
You’ve now learned four foundational concepts of Solidity: contract as the deployment unit, state variables for on-chain storage with three memory regions (storage/memory/calldata), functions with visibility and modifiers to control access, and events to communicate with off-chain systems. These concepts form the foundation for advancing to more complex topics like inheritance, interfaces, and standards like ERC-20/ERC-721 — all built on these same four blocks.