Solidity Fundamentals
You know the machine — the EVM, gas, opcodes. Now it's time to learn the language. Solidity is Ethereum's most popular smart contract language: statically typed, contract-oriented, and designed specifically for the EVM.
Recap: From EVM to Solidity
In Module 6, you learned that the EVM executes bytecode — low-level opcodes like PUSH, ADD, and SSTORE. But nobody writes bytecode by hand. You need a high-level language that compiles down to EVM bytecode. That language is Solidity.
What You Already Know
The EVM is a stack-based, deterministic, metered virtual machine. Every operation costs gas. Contracts have persistent storage. Transactions transform global state. You saw a SimpleStorage contract — now you'll understand every line of it.
Why Solidity?
Solidity is used by 90%+ of Ethereum smart contracts. It's the language behind Uniswap, Aave, OpenSea, and every major DeFi protocol. Alternatives exist (Vyper, Huff, Yul), but Solidity is the industry standard — and the language you'll use for the course project.
Module Goal
By the end of this module, you'll be able to read, understand, and write basic Solidity contracts. You'll know the type system, function visibility, modifiers, events, and error handling — everything you need before diving into advanced patterns in Module 8.
Solidity Basics: Anatomy of a Contract
A Solidity file is a .sol file. Every file starts with a pragma (compiler version), an optional SPDX license, and one or more contract definitions. Think of a contract like a class in object-oriented programming — it has state (variables) and behaviour (functions).
The Three Parts of Every .sol File
// SPDX-License-Identifier: MIT — Required since Solidity 0.6.8. Declares the open-source license. Without it, the compiler emits a warning.pragma solidity ^0.8.20; — Tells the compiler which versions can compile this file. The ^ means "0.8.20 or higher, but below 0.9.0".contract MyContract { ... } — The container for state variables, functions, events, modifiers, and errors. Deployed as a single unit to one address.The Compilation Pipeline
solc compilerWhat Is the ABI?
The ABI is the instruction manual for your contract. It lists every public/external function, its parameter types, and return types. Without the ABI, a frontend (or another contract) has no idea how to encode a function call into the calldata bytes the EVM expects.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BasicContract {
// State variables (stored on-chain)
uint256 public count;
address public owner;
// Constructor — runs once at deployment
constructor() {
owner = msg.sender;
}
// Function — modifies state
function increment() external {
count += 1;
}
}
Types & Variables
Solidity is statically typed — every variable must declare its type at compile time. The type system is designed around the EVM's 256-bit word size.
Value Types
| Type | Example | Notes |
|---|---|---|
uint256 |
uint256 count = 42; |
Unsigned integer, 0 to 2²⁵⁶−1. Default is 0. uint is alias for uint256. |
int256 |
int256 temp = -10; |
Signed integer. Range: −2²⁵⁵ to 2²⁵⁵−1. |
bool |
bool active = true; |
true or false. Default is false. |
address |
address owner = msg.sender; |
20-byte Ethereum address. address payable can receive ETH. |
bytes32 |
bytes32 hash = keccak256(...); |
Fixed-size byte array. bytes1 to bytes32 available. |
Reference Types
| Type | Usage | Key Detail |
|---|---|---|
string |
Text data | Dynamic size. Stored as bytes internally. No string manipulation built-in. |
bytes |
Raw byte data | Dynamic-size byte array. Prefer bytes over byte[] — cheaper gas. |
| Arrays | uint256[] ids |
Dynamic (uint[]) or fixed (uint[5]). Supports .push() and .length. |
mapping |
mapping(address => uint) |
Key-value store. Not iterable. All keys exist with default value 0. |
struct |
Custom data grouping | Like a C struct. Groups related variables. Very common for records. |
Data Locations: Where Variables Live
storage
Persistent on-chain. State variables are always in storage. Costs 20,000 gas to write a new slot (SSTORE). This is where your contract's permanent data lives.
memory
Temporary, per-call. Function parameters and local variables of reference types. Erased after the function returns. Much cheaper than storage.
calldata
Read-only input. Used for external function parameters. Cheapest option — data is not copied, just referenced. Always use calldata for external function parameters when possible.
Global Variables You'll Use Constantly
msg.sendermsg.valueblock.timestampblock.numbertx.originFunctions & Visibility
Functions are the behaviour of your contract. Solidity has strict visibility rules that determine who can call a function, and state mutability keywords that tell the EVM what the function does to state.
Visibility: Who Can Call This Function?
| Keyword | Callable from | Gas tip |
|---|---|---|
public |
Externally (transactions) + internally (other functions in this contract) | Creates a getter automatically for state variables. Slightly more gas than external. |
external |
Only from outside the contract (transactions or other contracts) | Cheaper for large inputs — reads directly from calldata. Use for functions only called externally. |
internal |
Only from this contract or contracts that inherit from it | Default for state variables. Like protected in Java/C++. |
private |
Only from this contract (not even child contracts) | ⚠️ Data is still visible on-chain! private only restricts contract-level access, not blockchain-level visibility. |
State Mutability: What Does It Do?
| Keyword | Can read state? | Can modify state? | Gas cost |
|---|---|---|---|
| (default) | ✅ Yes | ✅ Yes | Full gas cost — state changes are committed |
view |
✅ Yes | ❌ No | Free if called off-chain (via eth_call). Costs gas if called from another TX. |
pure |
❌ No | ❌ No | Free off-chain. Pure computation — no state access at all. |
The payable Keyword
A function marked payable can receive ETH. Without it, sending ETH to the function reverts. This is critical for crowdfunding contracts, marketplaces, and any contract that handles payments.
Special Functions
constructor()
Runs once at deployment. Used to set initial state (e.g., owner). Cannot be called again.
receive()
Called when the contract receives plain ETH (no calldata). Must be external payable.
fallback()
Called when no function matches the calldata, or when ETH is sent and no receive() exists. The catch-all.
How the EVM Finds Functions
When you call set(42), the wallet computes keccak256("set(uint256)") and takes the first 4 bytes — this is the function selector. The EVM uses it to route the call to the right function. This is why function names don't matter at the bytecode level — only the selector does.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract FunctionExamples {
uint256 public value;
// State-changing (costs gas)
function setValue(uint256 _v) external {
value = _v;
}
// View — reads state, free off-chain
function getValue() external view returns (uint256) {
return value;
}
// Pure — no state access at all
function add(uint256 a, uint256 b) external pure returns (uint256) {
return a + b;
}
// Payable — can receive ETH
function deposit() external payable {}
// Receive — plain ETH transfers
receive() external payable {}
}
Modifiers & Error Handling
Modifiers let you add reusable preconditions to functions. Error handling ensures your contract fails safely and reverts state when something goes wrong.
Custom Modifiers
A modifier is a reusable wrapper for function logic. The most common pattern is access control:
The onlyOwner Pattern
Instead of writing require(msg.sender == owner) in every admin function, you define it once as a modifier and apply it with a single keyword. The _; placeholder marks where the actual function body executes.
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_; // ← function body executes here
}
function withdraw() external onlyOwner {
// only the owner can call this
payable(msg.sender).transfer(address(this).balance);
}
Error Handling: Three Tools
require(condition, "message")
Validates inputs and preconditions. If the condition is false, the transaction reverts and remaining gas is refunded. Use for user-facing errors: "insufficient balance", "not authorized", etc.
revert("message")
Explicitly revert with a message. Same effect as a failed require, but useful in complex if/else branches where require doesn't fit cleanly.
assert(condition)
For internal invariants — conditions that should never be false. If an assert fails, it means there's a bug in your code. Uses all remaining gas (Solidity < 0.8.0) or reverts with error code (≥ 0.8.0).
Custom Errors (Gas Efficient)
Since Solidity 0.8.4, you can define error types that are much cheaper than string messages. Instead of require(x > 0, "Must be positive"), you write error MustBePositive(); and use if (x == 0) revert MustBePositive();. This saves ~50 bytes of deployment gas per error message.
Arithmetic Safety
Since Solidity 0.8.0, arithmetic overflow and underflow are automatically checked. If uint8 x = 255; x += 1; would overflow, the transaction reverts. Before 0.8.0, this silently wrapped around — a major source of exploits. You can opt out with unchecked { ... } when you're sure the math is safe (saves gas).
// Custom error — much cheaper than string messages
error InsufficientBalance(uint256 requested, uint256 available);
function withdraw(uint256 amount) external {
if (amount > balances[msg.sender])
revert InsufficientBalance(amount, balances[msg.sender]);
// ... transfer logic
}
Events & Logs
Events are Solidity's notification system. They write data to the transaction's log — a special, gas-efficient storage area that is not accessible to contracts, but can be read by off-chain applications.
Why Events Matter
DApp frontends listen for events in real-time (via WebSocket subscriptions) to update their UI
Indexers like The Graph process events to build queryable databases of on-chain activity
Debugging — events are the closest thing to console.log() in Solidity
Cheaper than storage — emitting an event costs ~375 gas (LOG0) vs 20,000 gas for an SSTORE
Syntax: Define and Emit
You define an event with the event keyword and emit it inside a function. Up to 3 parameters can be marked indexed, which makes them searchable (filterable) by off-chain tools.
// Define the event
event Transfer(
address indexed from,
address indexed to,
uint256 amount
);
// Emit it inside a function
function transfer(address _to, uint256 _amount) external {
// ... transfer logic ...
emit Transfer(msg.sender, _to, _amount);
}
Indexed Parameters
indexed parameters are stored as topics in the log entry (using LOG1–LOG4 opcodes). Non-indexed parameters are stored in the log's data field. Topics are searchable — for example, you can filter for all Transfer events involving a specific address. Maximum 3 indexed parameters per event (4 topics total, first is the event signature hash).
Gas Costs of Events
Best Practice: Emit Events on Every State Change
The community convention is to emit an event whenever your contract modifies state. This makes your contract auditable and your DApp reactive. OpenZeppelin contracts follow this pattern — every transfer(), approve(), and mint() emits an event.
Your First Real Contract: TodoList
Let's combine everything you've learned into a complete, annotated smart contract. This TodoList contract uses structs, mappings, events, modifiers, custom errors, and multiple function visibilities.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TodoList {
// ── State Variables ──
address public owner;
uint256 public taskCount;
// ── Struct ──
struct Task {
uint256 id;
string content;
bool completed;
}
// ── Mapping ──
mapping(uint256 => Task) public tasks;
// ── Custom Error ──
error NotOwner();
// ── Events ──
event TaskCreated(uint256 indexed id, string content);
event TaskToggled(uint256 indexed id, bool completed);
// ── Modifier ──
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
// ── Constructor ──
constructor() {
owner = msg.sender;
}
// ── Functions ──
function createTask(string calldata _content) external onlyOwner {
taskCount++;
tasks[taskCount] = Task(taskCount, _content, false);
emit TaskCreated(taskCount, _content);
}
function toggleTask(uint256 _id) external onlyOwner {
Task storage task = tasks[_id];
task.completed = !task.completed;
emit TaskToggled(_id, task.completed);
}
function getTask(uint256 _id) external view returns (Task memory) {
return tasks[_id];
}
}
Contract Breakdown
owner (address), taskCount (counter), tasks (mapping from ID to Task struct). All stored in persistent on-chain storage.Task groups id, content, and completed into a single reusable type — like a record in a database.error NotOwner() — gas-efficient alternative to string-based require messages.TaskCreated and TaskToggled — emitted on every state change so DApps can react.onlyOwner — restricts createTask and toggleTask to the contract deployer.createTask() (external, owner-only), toggleTask() (external, owner-only), getTask() (external view — free off-chain).Gas Analysis
Creating a task writes to 3 storage slots (taskCount + Task struct with 3 fields) and emits an event. Estimated cost: ~60,000–80,000 gas. Reading a task via getTask() is free when called off-chain.
What's Next?
This contract works but has limitations: anyone can call it if onlyOwner is removed, there's no way to delete tasks, and the storage grows forever. In Module 8, you'll learn Solidity patterns — access control libraries, proxy upgrades, storage optimisation, and more.
Exercise & Self-Check
Exercises
- Type analysis: For each variable, identify its type, data location, and gas cost category (cheap/medium/expensive):
uint256 count,string memory name,mapping(address => uint256) balances. - Visibility audit: You have a function that only the contract deployer should call, but other contracts might inherit from yours. What visibility should you use? Explain why.
- Event design: Design the events for a simple
Votingcontract. What fields would you index? Why? - Error refactoring: Rewrite these 3
requirestatements using custom errors. Estimate the gas savings. - Code reading: Given the TodoList contract above, trace the execution of
createTask("Buy milk"). List each storage write and event emission.
Self-Check Questions
- What is the difference between
memoryandcalldata? When should you use each? - Why is
externalcheaper thanpublicfor functions with large array parameters? - What happens if you call a
viewfunction from within a state-changing transaction? - Why can't you iterate over a
mapping? What pattern can you use instead? - Explain what
_;means inside a modifier and what happens if you omit it.
Module Summary
You can now read and write Solidity. You understand the type system (value types, reference types, data locations), function visibility and state mutability, modifiers for access control, events for off-chain communication, and error handling for safe reverts. In Module 8, you'll learn advanced Solidity patterns — inheritance, interfaces, the proxy pattern, and gas optimisation techniques.