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-Identifier line declares the license; the compiler will warn if it’s missing.
  • pragma solidity ^0.8.24 locks 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.0 for 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. The public keyword automatically generates a getter function owner().
  • uint256 public balance: the balance, an unsigned 256-bit integer — the most common numeric type in Solidity.
  • mapping(address => uint256): similar to HashMap<Address, BigInteger> but you cannot iterate keys; only look up by key.
  • constructor() runs exactly once at deployment; msg.sender is 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 to memory but read-only, used for parameters of external functions — more gas-efficient than memory.

Note: Forgetting to declare memory/calldata for array, struct, or string parameters 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 than public when parameters are large arrays/strings.
  • payable: allows the function to receive ETH with the transaction; msg.value is the amount of wei sent.
  • view: guarantees no state changes, costs no gas when called off-chain (via eth_call).
  • require(condition, "message"): checks a condition, reverts the entire transaction with a message if false — similar to throwing an IllegalArgumentException, 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 of transfer()/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 onlyOwner to the withdraw declaration makes the code read like “only the wallet owner can withdraw” — clearer than repeating require in 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 be indexed for 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/call for sending ETH: transfer is limited to 2300 gas and easily fails with receiving contracts that have complex logic (proxies, multisigs). Use call with require to check the result, as shown in section 4.
  • Forgetting require to 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 external vs. public in the wrong context: using public for functions called only from outside wastes gas when parameters are arrays/strings, because Solidity must copy data into memory instead of reading directly from calldata.
  • Not declaring view/pure when 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 public functions: if a function is never called from another contract or inherited, use external instead 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 from require.
  • changeEtherBalance checks 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.

solidityblockchainsmart-contractethereumweb3