4 - Solidity I.
A contract does exactly what you wrote. For everyone, every time, for as long as it exists.
Deployed code can’t be patched. Today we make sure what you wrote is what you meant: each chapter removes one way a contract can do something you didn’t mean.
Tutorial objectives
We will learn the Solidity fundamentals on one finished contract, LabLending (a lab that lends equipment to students). Then you will finish a second contract yourself, Attendance (an attendance register), in five small TODOs. Each TODO has a check contract that tells you when it works.
Tutorial pre-requisites
- VS Code - popular code editor, support for extensions (https://code.visualstudio.com/)
- Solidity (Wake) extension (https://marketplace.visualstudio.com/items?itemName=AckeeBlockchain.tools-for-solidity)
- compiles the contracts as you type and lets you deploy and call them in its Deploy & Interact panel
Solidity has good documentation at https://docs.soliditylang.org/en/latest/. It should be your first stop when you are looking for something.
How Ethereum runs code
Ethereum has two kinds of accounts, and only one of them can start a transaction:
| Externally owned account | Contract account | |
|---|---|---|
| Controlled by | a private key: your wallet | its code; nobody holds a key |
| Starts transactions | yes | no, it only reacts |
| Has code and storage | no | yes |
| In our example | Alice, the lab admin | LabLending |
Every node runs every transaction again, so whoever sends a transaction pays gas for every step of the code it runs. Borrowing one item in LabLending costs 72,628 gas.
Variables
Contract structure
This is the top of LabLending. A contract has six kinds of parts, and the rest of this page walks through them in this order:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract LabLending {
uint256 public constant MAX_LOAN = 7 days; // state variables
address public immutable admin;
Item[] private items;
event ItemAdded(uint256 indexed itemId, string name); // events
error NotAdmin(); // errors
modifier onlyAdmin() { // modifiers
if (msg.sender != admin) revert NotAdmin();
_;
}
constructor() { admin = msg.sender; } // the constructor
function addItem(string calldata name) external onlyAdmin { ... } // functions
}The first two lines start every Solidity file: the license, and which compiler versions may compile it.
Where you declare a variable decides how long it lives. State variables, declared in the contract body, live on chain between transactions. Local variables, declared in a function, exist only while it runs. Global variables, like block.timestamp, come from the EVM without any declaration:
Item[] private items; // state: on chain, between transactions
function daysLeft(uint256 itemId) external view returns (uint256) {
uint256 due = items[itemId].dueAt; // local: only while this call runs
if (due <= block.timestamp) return 0; // global: provided by the EVM
return _toDays(due - block.timestamp);
}Global variables
Two global variables describe every call: msg is about the call itself, block about the block the transaction lands in. They are filled in for every call, and every call in one transaction sees the same block.
| Global | What it holds | In our code |
|---|---|---|
msg.sender | the address that called this function | onlyAdmin, borrower |
msg.value | wei sent with the call, only in payable functions | next tutorial |
block.timestamp | the block’s time, in seconds since 1970 | dueAt, closesAt |
block.number | the block’s number: how many came before it | |
block.chainid | which network: 1 is Ethereum mainnet |
msg.sender is always your direct caller. When Alice calls contract A and A calls contract B, msg.sender is Alice inside A, but A inside B:
Alice (EOA, signs) → Contract A (msg.sender = Alice) → Contract B (msg.sender = A)
Every "only the admin can" check uses msg.sender. Inside the constructor, msg.sender is the deployer: that’s how LabLending picks its admin. The whole chain is one transaction: if anything in it reverts, every change is undone, and the gas used is still paid.
More global variables are listed in https://docs.soliditylang.org/en/latest/units-and-global-variables.html.
Value types
Four simple types cover our contracts:
| Type | Holds | Starts as | In our code |
|---|---|---|---|
bool | true or false | false | bool enrolled; |
uint256 | whole numbers from 0 | 0 | uint256 dueAt; |
address | a 20-byte account | address(0) | address borrower; |
enum | one of a few names | the first one | enum Status { Available, Borrowed } |
Integers come in sizes: uint8, uint16 … uint256 from 0, and int8, int16 … int256 with negatives, in steps of 8 bits. uint on its own means uint256, and that’s what we use everywhere. A uint8 holds up to 255; a calculation that goes past it reverts the transaction, it never wraps around.
Every type is described in https://docs.soliditylang.org/en/latest/types.html.
Structs and arrays
A struct groups values that belong together. One item in LabLending is four values that always go together, and you read or write a field with a dot: item.status.
enum Status { Available, Borrowed }
struct Item {
string name;
Status status;
address borrower; // address(0) when nobody has it
uint256 dueAt; // 0 when not borrowed
}An array keeps items in order, and an item’s id is simply its position. push adds to the end, length counts, and items[i] reads one item. Reading past the end reverts, so itemExists checks it first and the error says which id was wrong:
Item[] private items; // an item's id is its position
function addItem(string calldata name) external onlyAdmin {
items.push(Item({name: name, status: Status.Available, borrower: address(0), dueAt: 0}));
emit ItemAdded(items.length - 1, name); // the new item's id
}
modifier itemExists(uint256 itemId) {
if (itemId >= items.length) revert ItemNotFound(itemId); // past the end
_;
}Mappings
A mapping finds a value by its key, and only by its key. Every key already exists and holds zero, so a mapping has no length, no list of keys and can’t be looped over. Mappings can nest: one value per pair of keys. They live only in storage: you can’t create one in memory, so a function can only point to a mapping that already exists in the contract’s state.
mapping(address => bool) public isStudent; // LabLending
isStudent[alice] = true;
isStudent[bob]; // false: every key already "exists"
mapping(uint256 => mapping(address => bool)) public checkedIn; // Attendance
checkedIn[0][alice] = true; // session 0, student aliceTo list what’s in a mapping, keep its keys in an array and update both every time. Your TODO 1 is this pattern:
mapping(address => bool) public isStudent; // answers one key in one step
address[] private students; // remembers every key, in order
function addStudent(address student) external onlyAdmin {
if (isStudent[student]) revert AlreadyStudent(student);
isStudent[student] = true;
students.push(student);
emit StudentAdded(student);
}Default values
Everything starts at zero:
| Type | Starts as |
|---|---|
uint256 | 0 |
bool | false |
address | address(0) |
| struct | every field zero |
| mapping value | zero, for every key |
So "never enrolled" and "enrolled, 0 visits" look exactly the same. The fix is a flag you set yourself. Whenever a specification says "asking about something unknown reverts", this is the check:
struct Student {
bool enrolled; // false until enroll() sets it
uint256 attended;
}
function getAttendance(address student) external view returns (uint256) {
if (!students[student].enrolled) revert NotEnrolled(student);
return students[student].attended;
}Where data lives
Storage, memory and calldata
Inside a function, every struct, array and string names one of three places:
| storage | memory | calldata | |
|---|---|---|---|
| What it is | the contract’s state | scratch space for one call | the transaction’s input |
| Lasts | forever | until the call ends | until the call ends |
| Can change | yes | yes | no |
| In LabLending | Item[] items | returns (Item memory) | string calldata name |
Storage is by far the most expensive of the three; why, we’ll see next tutorial.
The place matters most when you write. A memory variable is a copy, and the write disappears when the call ends. A storage variable is a pointer into the contract’s state, and the write is saved. Neither version gives an error, so when you want to write, use a storage pointer. Attendance’s TODO 3 needs the second one.
// the trap: a copy; the write disappears when the call ends
Item memory item = items[itemId];
item.status = Status.Borrowed;
// the fix: a pointer into storage; the write is saved
Item storage item = items[itemId];
item.status = Status.Borrowed;Memory arrays
Storage arrays like items grow with push. A memory array gets its length once, when you create it with new, and the compiler refuses push:
function example() external pure {
uint256[] memory result = new uint256[](2); // length 2, fixed for good
result[0] = 1; // writing a slot: fine
result[1] = 3;
result.push(5); // compile error
}Error (4994): Member "push" is not available in uint256[] memory outside of storage.
So to return a list from a function, you need its size before you create it. getOverdueItems doesn’t know in advance how many items are overdue, so it goes through them twice: it counts, allocates an array of exactly that size, then fills it.
function getOverdueItems() external view returns (uint256[] memory) {
uint256 count = 0; // 1. count
for (uint256 i = 0; i < items.length; i++) {
if (_isOverdue(items[i])) count++;
}
uint256[] memory result = new uint256[](count); // 2. allocate, exact size
uint256 j = 0; // the next free slot
for (uint256 i = 0; i < items.length; i++) { // 3. fill
if (_isOverdue(items[i])) result[j++] = i; // write slot j, then move j on
}
return result;
}With 5 items where items 1 and 3 are overdue:
| Step | What happens | result |
|---|---|---|
| Count | items 1 and 3 match, so count = 2 | |
| Allocate | new uint256[](2) | [0, 0] |
Fill, i = 1 | result[0] = 1, then j becomes 1 | [1, 0] |
Fill, i = 3 | result[1] = 3, then j becomes 2 | [1, 3] |
Why not make the result items.length long? The unused slots would hold 0, and 0 is also a real item id. The extra task in Attendance, getAtRisk, uses the same three steps.
Functions
Visibility and mutability
Visibility decides who may call a function:
| From outside | From inside | In LabLending | |
|---|---|---|---|
public | yes | yes | borrow |
external | yes | no | addItem |
internal | no | yes, and children | _isOverdue |
private | no | this contract only | _toDays |
public variables get a free getter: isStudent can be read by calling isStudent(address). Private is not secret, though: anyone can read every storage slot of a contract.
Mutability decides what a function may change:
| Reads state | Changes state | In LabLending | |
|---|---|---|---|
pure | no | no | _toDays |
view | yes | no | getItem |
| no keyword | yes | yes | borrow |
Calling a view function from VS Code sends no transaction, so it costs nothing.
Return values
A function can return as many values as you need: list them in returns (…) and give them names. The caller receives all of them, in order. getLoan answers two questions at once, who has the item and until when:
function getLoan(uint256 itemId)
public view itemExists(itemId)
returns (address borrower, uint256 dueAt) // named return values
{
Item storage item = items[itemId];
return (item.borrower, item.dueAt);
}
(address who, uint256 due) = getLoan(0); // the caller takes bothWhen you call getLoan(0) in VS Code after borrowing, both values appear.
Constructor
The constructor runs once, at deployment, and msg.sender there is the deployer. That’s how LabLending knows who the admin is:
// LabLending
address public immutable admin;
constructor() {
admin = msg.sender; // whoever deploys becomes the admin
}A constructor can take arguments, and VS Code asks for them when you deploy. Attendance takes the session length: deploy it with 900 for a 15-minute session.
// Attendance
uint256 public immutable sessionLength;
address public immutable teacher;
constructor(uint256 length) {
teacher = msg.sender;
sessionLength = length; // 900 = 15 minutes
}Constants and immutables
A constant is fixed when compiling, an immutable is set once in the constructor. Both live in the code, not in storage:
| Set | Lives in | In our code | |
|---|---|---|---|
constant | when compiling | the bytecode, written in by the compiler | MAX_LOAN |
immutable | once, in the constructor | the bytecode, written in at deployment | admin, sessionLength |
| state variable | in any transaction | storage | items |
Neither takes a storage slot, so reading them is much cheaper than reading a state variable.
Checks and events
Errors and reverts
A custom error stops the call and undoes the whole transaction, events included. It can carry data, like which item failed. The caller still pays for the gas used up to that point.
error NotAvailable(uint256 itemId); // a custom error, with data
if (item.status != Status.Available) revert NotAvailable(itemId);
require(duration > 0, "Duration is zero"); // the older way: a text messageBecause one revert undoes everything, a batch is all or nothing for free: write one action with its checks and loop over it. That’s TODO 4.
function borrowBatch(uint256[] calldata itemIds, uint256[] calldata durations) external {
if (itemIds.length != durations.length) revert LengthMismatch();
for (uint256 i = 0; i < itemIds.length; i++) {
borrow(itemIds[i], durations[i]); // one revert undoes every borrow
}
}Modifiers
A check that repeats, like "only students", goes in a modifier once. The _; is where the function body runs. borrow uses two modifiers, and they run left to right: first is the caller a student, then does the item exist.
modifier onlyStudent() {
if (!isStudent[msg.sender]) revert NotStudent();
_; // the function body runs here
}
modifier itemExists(uint256 itemId) {
if (itemId >= items.length) revert ItemNotFound(itemId);
_;
}
function borrow(uint256 itemId, uint256 duration)
public onlyStudent itemExists(itemId) { ... } // run left to rightA modifier doesn’t have to end at the underscore: it can wrap the function. Code before _; runs first, code after it runs when the body is done, even if the body returns early, because a return only leaves the function body. The classic use is a lock that stops anyone calling back into the function while it’s still running (why anyone would, we’ll see next tutorial):
bool private locked;
modifier noReentry() {
if (locked) revert Locked();
locked = true; // before: runs first
_; // the function body
locked = false; // after: runs once the body is done
}With two such modifiers on one function, they nest like brackets, so the first one’s after-code runs last.
Events
Events tell the outside world what happened. They are stored with the transaction, not in storage. Contracts can’t read them; apps can. indexed parameters can be searched for.
event Borrowed(
uint256 indexed itemId, // indexed: searchable
address indexed student,
uint256 dueAt // plain data
);
emit Borrowed(itemId, msg.sender, item.dueAt);Important:
In the semestral project, events are graded, so emit exactly what the specification says.
Time and math
Time
block.timestamp is the only clock: seconds since 1970, one value for the whole transaction. Solidity has time units, so you don’t have to count seconds by hand:
| Unit | Seconds |
|---|---|
1 seconds | 1 |
1 minutes | 60 |
1 hours | 3,600 |
1 days | 86,400 |
1 weeks | 604,800 |
There is no years: not every year has the same number of seconds, so the compiler refuses it.
uint256 public constant MAX_LOAN = 7 days;
item.dueAt = block.timestamp + duration; // LabLending
uint256 public immutable sessionLength; // Attendance, e.g. 900
closesAt = block.timestamp + sessionLength;
function isSessionOpen() public view returns (bool) {
... // TODO 2: is closesAt itself open?
}The question to ask every time: is the boundary second itself in or out?
Integer division
There are no decimals. Division rounds down by throwing the fraction away, % gives you what was thrown away, and Solidity works from left to right:
| You write | Solidity works it out | Result |
|---|---|---|
7 / 2 | 3.5, fraction dropped | 3 |
7 % 2 | what 7 / 2 left over | 1 |
duration % 1 minutes == 0 | nothing left over | a whole number of minutes |
7 * 10 / 4 | left to right: 7 * 10 = 70, then 70 / 4 | 17 |
7 * (10 / 4) | brackets first: 10 / 4 = 2, then 7 * 2 | 14 |
anything / 0 | reverts |
The exact answer is 17.5. Same numbers, different order, different answer: multiply first, divide last. That is TODO 5.
Hands-on exercise
Get the starter files from the tutorial repository and open the folder in VS Code. Compiler 0.8.26.
contracts/LabLending.sol- the finished example used throughout this pagecontracts/Attendance.sol- the exercise, with the functions marked TODOchecks/AttendanceChecks.sol- one check contract per TODO
Play with LabLending
contracts/LabLending.sol is finished. In the extension’s Deploy & Interact panel:
- Deploy
LabLendingfrom the first account. That account is the admin. - Set up: call
addStudentwith the second account’s address, then add two items:addItem("Oscilloscope")andaddItem("Multimeter"). - Borrow: switch to the second account. Call
borrow(0, 60), thengetLoan(0): it returns two values. - Batch: call
borrowBatch([1, 0], [60, 60]). It reverts, and item 1 is still available. - Wait a minute, send any transaction, then call
getOverdueItems().
Then break it on purpose. Every row ends in an error from a different chapter:
| Try | You will see |
|---|---|
Borrow for 8 days (691200 seconds) | InvalidDuration |
| Borrow for 90 seconds | InvalidDuration: not whole minutes |
returnItem(0) from the admin account | NotBorrower(0) |
addItem from the second account | NotAdmin() |
In borrow, change Item storage item to Item memory item, redeploy, borrow and read the item | no error, but the item still says Available |
Build: Attendance
Fill in the functions marked TODO in contracts/Attendance.sol, one at a time. Each is one to six lines. Do them in order: a later check tells you if an earlier one is missing. To try the contract by hand, deploy it with 900 (a 15-minute session).
| TODO | Function | Uses | Check |
|---|---|---|---|
| 1 | enroll | mapping + array of keys, an error, an event | Check1Enroll |
| 2 | isSessionOpen, openSession | block.timestamp, the boundary second | Check2Sessions |
| 3 | _checkIn | a storage pointer, a nested mapping | Check3CheckIn |
| 4 | checkInBatch | all or nothing | Check4Batch |
| 5 | attendancePercent | multiply first, divide last | Check5Percent |
| extra | getAtRisk | count, allocate, fill | Check6AtRisk |
After each TODO, open checks/AttendanceChecks.sol, deploy the check with the TODO’s number and call run(). A check is just a contract we wrote: if the call goes through, the TODO works; if it reverts, the message says what to fix:
Check1Enroll.run() → "TODO 1 works. Look for StudentEnrolled in the log…" Check2Sessions.run() → revert "TODO 2: at exactly closesAt the session is closed (use <, not <=)" Check3CheckIn.run() → revert "TODO 3: attended should be 1 after one check-in. Writing to a memory copy?" Check4Batch.run() → revert "Finish TODO 3 first"
Recommended reading and resources
- Solidity documentation (https://docs.soliditylang.org/en/latest/)
- Types (https://docs.soliditylang.org/en/latest/types.html)
- Units and globally available variables (https://docs.soliditylang.org/en/latest/units-and-global-variables.html)
- Function modifiers (https://docs.soliditylang.org/en/latest/contracts.html#function-modifiers)
- Solidity (Wake) VS Code extension (https://marketplace.visualstudio.com/items?itemName=AckeeBlockchain.tools-for-solidity)