Go to course navigation

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

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 accountContract account
Controlled bya private key: your walletits code; nobody holds a key
Starts transactionsyesno, it only reacts
Has code and storagenoyes
In our exampleAlice, the lab adminLabLending

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.

GlobalWhat it holdsIn our code
msg.senderthe address that called this functiononlyAdmin, borrower
msg.valuewei sent with the call, only in payable functionsnext tutorial
block.timestampthe block’s time, in seconds since 1970dueAt, closesAt
block.numberthe block’s number: how many came before it
block.chainidwhich 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:

TypeHoldsStarts asIn our code
booltrue or falsefalsebool enrolled;
uint256whole numbers from 00uint256 dueAt;
addressa 20-byte accountaddress(0)address borrower;
enumone of a few namesthe first oneenum 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 alice

To 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:

TypeStarts as
uint2560
boolfalse
addressaddress(0)
structevery field zero
mapping valuezero, 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:

storagememorycalldata
What it isthe contract’s statescratch space for one callthe transaction’s input
Lastsforeveruntil the call endsuntil the call ends
Can changeyesyesno
In LabLendingItem[] itemsreturns (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:

StepWhat happensresult
Countitems 1 and 3 match, so count = 2
Allocatenew uint256[](2)[0, 0]
Fill, i = 1result[0] = 1, then j becomes 1[1, 0]
Fill, i = 3result[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 outsideFrom insideIn LabLending
publicyesyesborrow
externalyesnoaddItem
internalnoyes, and children_isOverdue
privatenothis 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 stateChanges stateIn LabLending
purenono_toDays
viewyesnogetItem
no keywordyesyesborrow

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 both

When 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:

SetLives inIn our code
constantwhen compilingthe bytecode, written in by the compilerMAX_LOAN
immutableonce, in the constructorthe bytecode, written in at deploymentadmin, sessionLength
state variablein any transactionstorageitems

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 message

Because 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 right

A 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:

UnitSeconds
1 seconds1
1 minutes60
1 hours3,600
1 days86,400
1 weeks604,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 writeSolidity works it outResult
7 / 23.5, fraction dropped3
7 % 2what 7 / 2 left over1
duration % 1 minutes == 0nothing left overa whole number of minutes
7 * 10 / 4left to right: 7 * 10 = 70, then 70 / 417
7 * (10 / 4)brackets first: 10 / 4 = 2, then 7 * 214
anything / 0reverts

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 page
  • contracts/Attendance.sol - the exercise, with the functions marked TODO
  • checks/AttendanceChecks.sol - one check contract per TODO

Play with LabLending

contracts/LabLending.sol is finished. In the extension’s Deploy & Interact panel:

  1. Deploy LabLending from the first account. That account is the admin.
  2. Set up: call addStudent with the second account’s address, then add two items: addItem("Oscilloscope") and addItem("Multimeter").
  3. Borrow: switch to the second account. Call borrow(0, 60), then getLoan(0): it returns two values.
  4. Batch: call borrowBatch([1, 0], [60, 60]). It reverts, and item 1 is still available.
  5. Wait a minute, send any transaction, then call getOverdueItems().

Then break it on purpose. Every row ends in an error from a different chapter:

TryYou will see
Borrow for 8 days (691200 seconds)InvalidDuration
Borrow for 90 secondsInvalidDuration: not whole minutes
returnItem(0) from the admin accountNotBorrower(0)
addItem from the second accountNotAdmin()
In borrow, change Item storage item to Item memory item, redeploy, borrow and read the itemno 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).

TODOFunctionUsesCheck
1enrollmapping + array of keys, an error, an eventCheck1Enroll
2isSessionOpen, openSessionblock.timestamp, the boundary secondCheck2Sessions
3_checkIna storage pointer, a nested mappingCheck3CheckIn
4checkInBatchall or nothingCheck4Batch
5attendancePercentmultiply first, divide lastCheck5Percent
extragetAtRiskcount, allocate, fillCheck6AtRisk

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"