5 - Solidity II.
One contract is a tool. Several contracts, wired together, are a system.
Last week every contract stood alone. Today we add what a real system needs: money moving in and out, code you reuse instead of rewriting, and contracts that call each other. Your semestral task needs all of it.
Tutorial objectives
We will learn how contracts handle ETH, reuse code and call each other, mostly on one finished example: ClassFund (a class saving up together) and Donor (a contract that donates to it). We will see how tokens work, what a call looks like underneath, and how a contract can be upgraded although its code can’t change. Then you will finish two contracts yourself, MealCredits and Canteen, in five small TODOs, and wire them together. 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.
Gas
Last tutorial promised two answers: what gas costs in money, and why storage is the expensive place.
ETH has three units you will meet. Contracts only ever count in wei, the smallest one:
| Unit | In wei | Used for |
|---|---|---|
1 wei | 1 | what contracts count in |
1 gwei | 1,000,000,000 (109) | gas prices |
1 ether | 1,000,000,000,000,000,000 (1018) | amounts people send |
In code, write the unit after the number, like the time units: 0.001 ether, never the long number. One missing zero changes the price a thousand times.
The fee for a transaction is the gas it used times the gas price. The gas used comes from your code: the same call always costs the same. The gas price is what the sender offers per unit of gas, and it rises when the network is busy. Borrowing one item in LabLending uses 72,628 gas; at 2 gwei the fee is 145,256 gwei, about 0.000145 ETH. You control the gas used; the market sets the price. The price has two parts: a base fee, which the network sets for each block and burns, and a tip on top, which goes to the validator who puts the transaction into a block. Every node runs the transaction, but only that validator is paid, and you only pay for the gas actually used, not the limit you set.
Not every instruction costs the same. Every node keeps every contract’s storage forever, so writing a new storage slot is the most expensive thing a contract does:
| Operation | Gas |
|---|---|
| Add two numbers, or read or write memory | 3 |
Read a constant or immutable | 3: the value is written into the code |
| Read storage, first time in a transaction | 2,100 |
| Read storage again | 100 |
| Write storage, zero to non-zero | 20,000 + 2,100 |
Change a constant or immutable | impossible: fixed in the code |
| Start any transaction | 21,000 |
So count storage reads and writes, not lines of code: addItem in LabLending costs 74,076 gas, most of it for the new storage slots. Constants and immutables from the last tutorial are the cheap alternative: their value is written into the contract’s code, so reading one costs about 3 gas instead of 2,100, but they can never change. Anything that is fixed after deployment, like a price or another contract’s address, should be one of them.
Since Solidity 0.8, every +, -, *, ++ and -- is checked: a uint8 past 255 reverts instead of wrapping around. Each check costs a little gas, and unchecked { } removes exactly those checks and nothing else:
| Operation | Normally | Inside unchecked { } |
|---|---|---|
uint8: 255 + 1 | reverts | 0: wraps around |
uint256: 0 - 1 | reverts | 115792…639935: the largest number |
arr[5] when the array has 3 items | reverts | still reverts |
x / 0 | reverts | still reverts |
for (uint8 i = 0; i ⇐ 255; ) { …; unchecked { i++; } } | reverts at 255 + 1 | loops forever: 255 + 1 is 0 |
So use it only where you have just proven the result can’t overflow, like a subtraction right after you checked the balance is big enough:
if (amount > balance[msg.sender]) revert NotEnough();
unchecked { balance[msg.sender] -= amount; } // safe: the line above proves it can't go below 0The saving is small, about 14 gas per operation in our test. In the usual loop, for (uint256 i = 0; i < n; i)`, the compiler already skips the check on `i by itself since version 0.8.22, because i < n proves that i + 1 still fits. It only does that when it can prove it: with i ⇐ n, or a uint8 counter compared with a bigger number, the check stays, and the loop reverts at 255 + 1 instead of wrapping to 0. Use uint256 for loop counters. A wrong unchecked is a real bug: 0 minus 1 turns an empty balance into the largest number there is. That’s why auditors read every one.
One level further down is inline assembly. Inside assembly { } you write EVM instructions directly, in a language called Yul, and none of Solidity’s checks apply:
function addChecked(uint256 a, uint256 b) external pure returns (uint256) {
return a + b; // at the largest number: reverts
}
function addAssembly(uint256 a, uint256 b) external pure returns (uint256 r) {
assembly {
r := add(a, b) // a raw EVM instruction: wraps to 0, no check
}
}add is the EVM’s own addition: at the largest number it wraps to 0 without an error, while Solidity’s + reverts. Assembly is used in heavily optimised libraries, and auditors read it line by line. You won’t need it in this course.
Yul can also read and write memory and storage directly, by position instead of by name:
uint256 public count; // slot 0
function setDouble(uint256 x) external {
assembly {
mstore(0x00, x) // memory: write x at byte 0
let y := mload(0x00) // memory: read it back
sstore(0, add(y, y)) // storage: slot 0 (count) = 2 * x
}
}mstore and mload write and read memory, sstore and sload write and read a storage slot. Slot 0 is where count lives, so after setDouble(21), count is 42. Solidity’s globals have Yul names too: caller() is msg.sender and callvalue() is msg.value. Inline assembly is described in https://docs.soliditylang.org/en/latest/assembly.html, and every instruction is listed in https://docs.soliditylang.org/en/latest/yul.html#evm-dialect.
The example: ClassFund
A class saves up together. Three people, three functions, one contract holding the money:
| Step | Who | Calls | What happens in ClassFund |
|---|---|---|---|
| 1 | classmates | contribute() with ETH | the ETH stays in the contract |
| 2 | the treasurer, who deployed it | approveExpense(Carol, 1 ETH) | notes down: we owe Carol 1 ETH |
| 3 | Carol | withdraw() | sends Carol her 1 ETH |
Donor is a second contract that donates to the fund on someone’s behalf.
The example is four files: Owned.sol, IClassFund.sol, ClassFund.sol and Donor.sol. You will click through all of it in the hands-on part.
Ether in and out
payable: the only way in
A call can carry ETH, but only a payable function accepts it. Inside it, msg.value says how much came, in wei. Send ETH to any other function and the whole call reverts, so the ETH stays with the sender. The compiler even refuses to let a public function read msg.value unless it is payable:
function contribute() external payable { // payable: may receive ETH
totals[msg.sender] += msg.value; // msg.value: how much came, in wei
}
function rename(string calldata n) external { // not payable
uint256 v = msg.value; // doesn't compile
}Error (5887): "msg.value" and "callvalue()" can only be used in payable public functions.
callvalue() in the message is the same value as msg.value, under the name it has in inline assembly.
Keep ETH and gas apart: the ETH you attach is not used up by running the code, only gas is, and gas is paid separately. If the call succeeds, the contract keeps all the ETH, even if its code never looks at msg.value. If the call reverts, all of it goes back to the sender, who only loses the gas fee. And a contract that has no function sending ETH out keeps it forever. An example, with fees made large so you can see them: your wallet has 5 ETH and you attach 1 ETH.
| The call | The contract gets | Gas fee you pay | Your wallet after |
|---|---|---|---|
| succeeds, fee 0.2 ETH | 1 ETH | 0.2 ETH | 5 - 1 - 0.2 = 3.8 ETH |
| reverts after 0.1 ETH of fee | 0 | 0.1 ETH | 5 - 0.1 = 4.9 ETH |
The fee never goes to the contract; it is burned and tipped to the validator, as in the Gas section. Real fees are far smaller: borrowing in LabLending costs about 0.000145 ETH.
Whatever came in becomes part of the contract’s own balance, address(this).balance, and only the contract’s own code can send it out again.
Sending ETH, and receive()
There are three ways for a contract to send ETH:
| You write | If it fails | Gas budget for the receiver’s code | Use it? |
|---|---|---|---|
to.transfer(amount) | reverts | at most 2,300 | no |
to.send(amount) | returns false | at most 2,300 | no |
to.call{value: amount}("") | returns false | as much as it needs | yes, and check the result |
Two different things travel with a payment. The ETH, amount, is the money: the receiver gets all of it, or nothing if the payment fails. The gas is not money for the receiver: it is a budget for running the receiver’s code while it accepts the money, and whatever isn’t used goes back to the caller. A normal wallet has no code, so nothing runs and it always gets the ETH.
transfer and send are older, work only on an address payable, and allow the receiving contract’s receive() at most 2,300 gas. That is enough to accept the ETH and do nothing else. The receive() of ClassFund records the contribution, which writes to storage, and one new storage write alone costs 20,000 gas. So transfer to ClassFund runs out of gas and reverts, the ETH stays with the sender, and send returns false. Use call: it lets the receiver’s code use as much gas as it needs, and the unused rest goes back. It doesn’t revert on failure, so always check what it returns.
receive() is a special function, written without the function keyword. It does not run every time the contract gets ETH: it runs only when ETH arrives without a function call.
receive() external payable { // no "function" keyword, no name
_contribute(); // ClassFund: count it as a contribution
}| What happens | What runs |
|---|---|
| Alice presses Send in her wallet: 0.01 ETH to ClassFund | ClassFund’s receive() |
a contract sends it: fund.call{value: 0.01 ether}("") | ClassFund’s receive() |
Alice calls contribute() with 0.01 ETH | ClassFund’s contribute(), not receive() |
ClassFund pays Carol in withdraw() | Carol’s receive(), if Carol is a contract |
A contract without receive() rejects plain ETH. A second special function, fallback(), runs for calls to functions the contract doesn’t have; it is explained under the hood below.
When you send ETH to a contract, its receive() runs in the middle of your function, and it can call you back. That’s why the order matters: update your records first, send the ETH last.
function withdraw() external {
uint256 amount = owed[msg.sender];
if (amount == 0) revert NothingToWithdraw();
owed[msg.sender] = 0; // 1. update the records
totalOwed -= amount;
(bool ok, ) = msg.sender.call{value: amount}(""); // 2. send the ETH last
if (!ok) revert TransferFailed(); // and check it worked
}Had withdraw sent first and set owed to zero afterwards, the receiver’s receive() could call withdraw again and be paid twice. The lock modifier from the last tutorial is the other guard against this.
Notice also that ClassFund never sends money on its own. Imagine it paid 30 classmates their share in one loop: if just one of them is a contract that refuses ETH, the whole transaction reverts and nobody is paid. So approveExpense only writes down how much each person is owed, and each person calls withdraw for their own money. A broken receiver then blocks only their own withdrawal.
Building on other code
Everything you deploy is a normal contract. Solidity has three other kinds, and they all exist so you don’t write the same code twice:
| Kind | What it is | Why you use it | In our code |
|---|---|---|---|
contract | code and storage | the thing you deploy and people call | ClassFund, Donor |
abstract contract | a parent, never deployed alone | write shared code once, like an owner check | Owned |
interface | only function names and types | call a contract without having its code; agree on a standard like ERC-20 | IClassFund |
library | helper functions, no storage | reuse the same calculations in many contracts | Percent, below |
Inheritance
ClassFund didn’t write its owner check from scratch. is Owned gives it everything Owned has: the owner variable and the onlyOwner modifier. The constructor of Owned needs to know the owner, so ClassFund passes it in its own constructor: the deployer.
abstract contract Owned { // the parent
address public owner;
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor(address initialOwner) { owner = initialOwner; }
}
contract ClassFund is Owned { // the child gets owner and onlyOwner
constructor() Owned(msg.sender) {} // and passes the parent its argument
function approveExpense(address to, uint256 amount) external onlyOwner { ... }
}Only ClassFund is deployed, with the code of Owned copied inside it.
A parent marks a function virtual to let children change it. The child writes its own version marked override, and with super it can still call the parent’s version, so it only adds what’s new:
// Owned.sol: virtual means children may change it
function transferOwnership(address newOwner) public virtual onlyOwner { ... }
// ClassFund.sol: override replaces it, super calls the parent's version
function transferOwnership(address newOwner) public override onlyOwner {
if (newOwner == address(0)) revert ZeroAddress(); // the extra check
super.transferOwnership(newOwner); // then the parent's code
}A contract can have several parents. Solidity puts them all in one line, and the parent written rightmost is the closest to the child. super always means the next contract in that line:
contract A { function who() ... virtual returns (string memory) { return "A"; } }
contract B is A { function who() ... override ... { return string.concat("B", super.who()); } }
contract C is A { function who() ... override ... { return string.concat("C", super.who()); } }
contract D is B, C {
function who() public pure override(B, C) returns (string memory) {
return string.concat("D", super.who());
}
}
new D().who() → "DCBA"The line is D → C → B → A, so inside D, the super of C is B, even though C never mentions B. List the parents from the most basic to the most specific. If two parents have the same function, the child must override it and name both: override(B, C).
Interfaces and libraries
In our example, Owned is abstract: a parent that’s never deployed alone. IClassFund is an interface: it lists functions, events and structs, without any code.
interface IClassFund {
struct Contributor { address account; uint256 total; }
event Contributed(address indexed from, uint256 amount);
function contribute() external payable;
function withdraw() external;
function getTopContributors() external view returns (Contributor[] memory);
}An interface has two uses. A contract that says is IClassFund promises to implement all of it, and the compiler refuses to build ClassFund until every function is there. And other contracts use the interface to call ClassFund; they refer to its struct as IClassFund.Contributor. Your semestral task gives you interfaces like this one: implement them exactly.
Inheriting an interface also brings in everything it declares besides functions: ClassFund uses the struct Contributor and emits Contributed without declaring either, because they come from IClassFund.
The indexed on an event parameter means apps can search the log by that value, for example every Contributed event where from is Alice. An event can have up to 3 indexed parameters.
A library is a set of helper functions with no storage of its own. Write one in its own file and import it wherever you need it, or import one somebody else wrote. OpenZeppelin’s are the standard; install them with npm install @openzeppelin/contracts.
// Percent.sol: our own library
library Percent {
function percentOf(uint256 amount, uint256 pct) internal pure returns (uint256) {
return amount * pct / 100;
}
}
// Shop.sol
import {Percent} from "./Percent.sol"; // a file next to this one
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; // an installed package
contract Shop {
function fee(uint256 price) external pure returns (uint256) {
uint256 cut = Percent.percentOf(price, 5); // library name, then the function
return Math.max(cut, 1); // at least 1
}
}You call a library function with the library’s name in front: Percent.percentOf(price, 5), Math.max(cut, 1). In other code you’ll also see using Percent for uint256; followed by price.percentOf(5): a shorthand for exactly the same call. internal library functions are simply copied into your contract when it compiles.
To sum up, the compiler enforces these differences:
| contract | abstract contract | interface | library | |
|---|---|---|---|---|
| Function bodies | all | some can be missing | none | all |
| State variables | yes | yes | no | constants only |
| Constructor | yes | yes | no | no |
| Deployed on its own | yes | no | no | only if it has public functions |
| Used with | deploy it | is | is, or to call others | import, then L.f(x) |
Contracts working together
To call a contract you need two things: its address and its interface. Donor gets the address of ClassFund once, in the constructor, and wraps it in the interface. From then on fund.contribute() is a normal function call, and {value: ...} passes ETH along.
contract Donor {
IClassFund public immutable fund; // the contract we talk to
constructor(address fundAddress) {
fund = IClassFund(fundAddress); // wrap the address in the interface
}
function donate() external payable {
fund.contribute{value: msg.value}(); // call it, passing the ETH on
}
}Why IClassFund and not ClassFund itself? Importing ClassFund and writing ClassFund fund would also compile, but the interface is better:
- Donor only needs the list of functions, not ClassFund’s code or storage.
- You often don’t have the other contract’s code at all. USDC or Aave were written and deployed by someone else; their interface file is enough to call them.
- One interface fits many contracts:
IERC20works with every token, whoever wrote it.
When Alice calls donate with 1 ETH, Donor passes the same ETH on by writing {value: msg.value} on its call. Each contract only sees its direct caller:
Inside Donor | Inside ClassFund | |
|---|---|---|
msg.sender | Alice | Donor |
msg.value | 1 ETH | 1 ETH, because Donor wrote {value: msg.value} |
tx.origin | Alice | Alice |
So ClassFund records Donor as the contributor, not Alice. tx.origin is always whoever signed the transaction, at the start of the chain. Never use it to check who is allowed to do something. Here is the attack:
contract Wallet { // the owner's wallet
address public owner = msg.sender;
function withdrawAll(address to) external {
require(tx.origin == owner, "not the owner"); // the wrong check
(bool ok, ) = to.call{value: address(this).balance}(""); require(ok);
}
}
contract FakeAirdrop { // the attacker's contract
address immutable attacker = msg.sender;
function claim(Wallet w) external { w.withdrawAll(attacker); } // the owner calls this
}The attacker deploys FakeAirdrop and tricks the owner into calling claim. FakeAirdrop then calls withdrawAll on the owner’s Wallet. Inside Wallet, tx.origin is the owner, because the owner signed the transaction, so the check passes and the attacker gets everything. With require(msg.sender == owner) instead, msg.sender is the direct caller, FakeAirdrop, so the call fails and the money is safe. tx.origin only tells you who started; msg.sender tells you who is actually asking.
If anything in the chain reverts, Alice’s whole transaction reverts.
Before deploying several contracts, work out who needs whose address:
- One needs the other: deploy that one first and pass its address to the constructor.
Donorgets the address ofClassFund. This is always the best option: the address can beimmutable, set once and never wrong later. - Both need each other: then one has to be deployed without the other’s address, and you set it afterwards. Make that function owner only, and allow it only once, so nobody can redirect it later. Until it is set, the address is
address(0).
For example, a shop sends money to a treasury, and the treasury only accepts money from the shop:
contract Shop is Owned {
address public treasury; // address(0) until set
function setTreasury(address t) external onlyOwner {
if (treasury != address(0)) revert AlreadySet(); // only once
treasury = t;
}
}
contract Treasury {
address public immutable shop;
constructor(address s) { shop = s; } // knows the Shop from the start
}Deploy the Shop, then the Treasury with the shop’s address, then call setTreasury. Your semestral task has this case, and TODO 3 of the exercise is this pattern.
Tokens from the inside
In tutorial 2 you used ERC-20 tokens on Aave: one transaction to approve, one to supply. Now you can see how that looks inside the token’s code. A token is a contract with a mapping from addresses to amounts. Owning 30 tokens means that number is 30 next to your address in the token contract; nothing is in your wallet. Only the token’s code can change the numbers, and transfer only ever moves the caller’s own tokens:
contract Token {
mapping(address => uint256) public balanceOf; // your tokens: a number here
function transfer(address to, uint256 amount) external {
balanceOf[msg.sender] -= amount; // always the caller's own
balanceOf[to] += amount; // reverts if not enough
}
}So a shop contract can’t just take your tokens. Instead, you give it permission first, and the payment takes two transactions, just like approve and supply on Aave:
1. You → Token: approve(shop, 30) allowance[you][shop] = 30 2. You → Shop: buy() → Token: transferFrom(you, shop, 10)
Inside transferFrom, the names are easy to mix up. The shop calls transferFrom(you, shop, 10): move 10 of your tokens to the shop.
| Name | What it is | Here | Meaning |
|---|---|---|---|
msg.sender | global variable | the shop | who is calling: the spender |
from | 1st parameter | you | whose tokens move |
to | 2nd parameter | the shop | where they go: here back to the spender |
amount | 3rd parameter | 10 | how many |
allowance[from][msg.sender] | storage | 30 → 20 | what you allowed the shop |
So transferFrom checks the allowance from gave to msg.sender and the balance of from, lowers the allowance, and then moves the tokens. Without step 1, step 2 reverts: the shop has no permission.
Because every token has the same functions, any wallet or exchange can work with any token. This standard is ERC-20:
| Function | What it does |
|---|---|
balanceOf(owner) | tokens of one address |
totalSupply() | all tokens in existence |
transfer(to, amount) | move your own tokens |
approve(spender, amount) | let spender move up to amount of yours |
allowance(owner, spender) | how much is still allowed |
transferFrom(from, to, amount) | the spender moves tokens of from |
Every change also emits an event: Transfer or Approval. In today’s exercise, MealCredits is a small token with its own names: allow is approve and spendFrom is transferFrom.
Under the hood
The ABI
When you call approveExpense from VS Code, these are the bytes that are sent:
approveExpense(0x70997970C51812dc3A010C7d01b50e0d17dc79C8, 1 ether)
0x20bcab01 // which function
00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 // to
0000000000000000000000000000000000000000000000000de0b6b3a7640000 // amount: 1 ether in weiThe first 4 bytes, the selector, pick the function: they are the start of keccak256("approveExpense(address,uint256)"). Then each argument is padded to one 32-byte word. This encoding is the Application Binary Interface (ABI), and it is the same whether a wallet calls a contract or one contract calls another: when Donor calls ClassFund, the compiler builds these bytes from the interface. In code, IClassFund.approveExpense.selector gives you the 4 bytes.
call sends exactly such bytes to another contract, and you can build them yourself:
// call a function: the function from the interface, then its arguments in ( )
(bool ok, ) = address(fund).call(abi.encodeCall(IClassFund.approveExpense, (carol, 1 ether)));
// call a function and attach ETH
(ok, ) = address(fund).call{value: 1 ether}(abi.encodeCall(IClassFund.contribute, ()));
// no bytes at all: it only sends ETH, and the receiver's receive() runs
(ok, ) = to.call{value: 0.5 ether}("");
// what you normally write instead: the compiler builds the same bytes
fund.contribute{value: 1 ether}();abi.encodeCall takes the function from the interface and its arguments in a second pair of brackets, in order; () means none. The compiler checks the argument types. Older code uses abi.encodeWithSignature("approveExpense(address,uint256)", carol, 1 ether), which builds the same bytes from the name written as text, without any type check.
The ETH comes from the calling contract’s own balance, not automatically from msg.value. To pass on exactly what your caller sent, write {value: msg.value}, like Donor does.
For the first line, abi.encodeCall builds exactly the bytes shown above, with Carol at 0x7099…79C8.
Every contract looks at what you sent and picks one piece of code. If none fits, the call reverts:
| You send | The contract has | What runs |
|---|---|---|
a call to withdraw() | a withdraw() function | withdraw() |
| 1 ETH, no function named | receive() | receive() |
| 1 ETH, no function named | no receive(), but a payable fallback() | fallback() |
| 1 ETH, no function named | neither | nothing: it reverts, the ETH stays with you |
| a call to a function it doesn’t have | fallback() | fallback() |
| a call to a function it doesn’t have | no fallback() | nothing: it reverts |
Calling a function the contract doesn’t have usually means you called the wrong contract, for example through the wrong interface: the selector is a hash of the name and types, so it doesn’t match anything. A proxy uses this on purpose, as the next section shows: it has almost no functions, so every call ends up in its fallback().
Calls and upgrades
There are three kinds of call from one contract to another:
call | delegatecall | staticcall | |
|---|---|---|---|
| Whose code runs | the other contract’s | the other contract’s | the other contract’s |
| Whose storage changes | the other contract’s | mine | nobody’s: read only |
msg.sender inside | me | my caller | me |
| Used for | normal calls, sending ETH | proxies, libraries | calling view functions |
A normal call is what you’ve done all along: calling a function of another contract, with or without ETH. fund.contribute() in Donor is one, and sending plain ETH is a call that names no function. staticcall is a call that may only read: Solidity uses it by itself whenever you call a view function of another contract, like fund.getOwed(me), and if that code tries to write anything, the call reverts.
delegatecall is the unusual one: it runs the other contract’s code as if it were mine, on my storage. The same function, called both ways:
contract Counter { uint256 public count; function increment() external { count += 1; } }
contract Caller {
uint256 public count; // same variable, same place
function viaCall(address counter) external {
(bool ok, ) = counter.call(abi.encodeCall(Counter.increment, ()));
require(ok); // Counter's count + 1
}
function viaDelegatecall(address counter) external {
(bool ok, ) = counter.delegatecall(abi.encodeCall(Counter.increment, ()));
require(ok); // Caller's own count + 1
}
}After viaCall, Counter’s count is 1. After viaDelegatecall, Counter’s code ran, but it was Caller’s own count that became 1; Counter wasn’t changed. That’s what makes upgrades possible.
delegatecall matches variables by position, not by name. Storage has no names or types, only numbered 32-byte slots. count is in slot 0 of Counter, so increment() adds 1 to slot 0, and under delegatecall that is the caller’s slot 0, whatever it holds:
| The caller’s first variable (slot 0) | Before | After delegatecall increment() |
|---|---|---|
uint256 counting | 5 | 6: the name doesn’t matter |
address owner | 0x7099…79C8 | 0x7099…79C9: a different owner |
bool open | false | true |
the proxy’s implementation, if V1 forgets ProxyStorage | V1’s address | V1’s address + 1: the proxy is broken |
Nothing checks the types and nothing reverts: the code that runs simply decides what the bytes mean. That’s why a new version of an upgradeable contract must keep the old variables in exactly the same order and types, and add new ones only after them.
Deployed code can never change, bugs included. There are two ways to ship a fix:
| Deploy a new contract | Use a proxy | |
|---|---|---|
| Address | new: every user and contract must switch | stays the same |
| Data | stays in the old contract: copy it over yourself | stays in the proxy |
| What you deploy | the new version | the new version, then one upgradeTo call |
Either way you deploy new code. A proxy saves you from moving the address and the data, thanks to delegatecall:
Donor --call--> ClassFund (ClassFund's code, ClassFund's storage) Proxy --delegatecall--> CounterV1 (V1's code, the Proxy's storage)
When the proxy delegatecalls CounterV1, V1’s count += 1 runs, but the count it changes lives in the proxy. So the data lives in one place, the proxy, and the code can live somewhere else. These are the two versions of the code in Upgrade.sol:
contract CounterV1 is ProxyStorage { // ProxyStorage: implementation, admin
uint256 public count;
function increment() external { count += 1; }
}
contract CounterV2 is ProxyStorage {
uint256 public count; // same variables, same order
function increment() external { count += 10; } // changed
function reset() external { count = 0; } // new
}Nobody calls V1 or V2 directly; their own count stays 0. The proxy isn’t a website or a server: it’s an ordinary contract, Proxy in Upgrade.sol, sitting between the users and the code. It never declares count: V1’s code simply writes slot 2 of the proxy. The proxy’s own two variables, implementation and admin, sit in slots 0 and 1, so every version starts with is ProxyStorage to keep count out of their way. A version that forgets it puts count in slot 0, and the first increment adds 1 to implementation: the proxy then points at an address with no code, and every later call silently does nothing. (Real proxies keep implementation in a far-away slot, defined by the standard EIP-1967, so it can’t collide.) The proxy has only two functions: upgradeTo for the admin, and a fallback() that delegatecalls whichever version it points at. An upgrade, step by step:
- Deploy
CounterV1. Only the code; nobody calls this address directly. - Deploy the
Proxywith V1’s address. It remembers: my code is at V1. - Alice calls
increment()on the proxy. The proxy has no such function, so itsfallback()delegatecalls V1:countis 1. - Deploy
CounterV2. The new code, at a new address. - The admin calls
upgradeTo(V2). Now the proxy remembers: my code is at V2.countis still 1. - Alice calls
increment()on the same proxy address. V2’s code runs:countis 11.
Nothing moves to a new address, and no data is copied: it was in the proxy all along. upgradeTo only overwrites one variable inside the proxy. Follow the storage of all three contracts:
| Step | Proxy’s storage | V1’s storage | V2’s storage |
|---|---|---|---|
| 1. deploy V1, then the Proxy with V1’s address | implementation = V1, count = 0 | count = 0 | - |
2. Alice calls increment() on the proxy | implementation = V1, count = 1 | count = 0 | - |
3. deploy V2; the admin calls upgradeTo(V2) | implementation = V2, count = 1 | count = 0 | count = 0 |
4. Alice calls increment() on the proxy | implementation = V2, count = 11 | count = 0 | count = 0 |
V1 and V2 only lend their code; their own count is never used, so there is nothing to transfer. It works like an app update on your phone: same number, same contacts, new app.
You use proxies all the time without noticing. USDC on Ethereum is one: open it on Etherscan and the Contract tab shows "Read as Proxy" and "Write as Proxy", along with the list of its past upgrades.
It comes at a price:
- V2 must keep V1’s state variables in the same order, or it reads the wrong data.
- Whoever is the admin can replace all the code at any moment, so an upgradeable contract is only as safe as its admin.
Most large protocols are upgradeable, so always check who can upgrade a contract before you trust it.
Hands-on exercise
Get the starter files from the tutorial repository and open the folder in VS Code. Compiler 0.8.26. The repository has two folders, so you always know what to deploy:
| Folder | What is in it | What you do |
|---|---|---|
1-examples/ | Owned, IClassFund, ClassFund, Donor, Upgrade | click through ClassFund and Donor; Upgrade is shown in class |
2-exercise/ | IMealCredits, MealCredits, Canteen | fill in five TODOs |
2-exercise/checks/ | CanteenChecks | deploy one check per TODO and call run() |
Only 2-exercise has TODOs. Canteen imports Owned from 1-examples: the same parent contract that ClassFund uses.
Play with the examples
The upgrade in Upgrade.sol is shown in class as a demo; you don’t have to deploy it. It follows the storage table in the Calls and upgrades section above: the count survives the upgrade, and count() on V1 and V2 themselves stays 0.
Then click through ClassFund and Donor yourself:
- Deploy
ClassFundfrom the first account. That account is the treasurer. - Deploy
Donorwith the address ofClassFundin the constructor field. - Donate: from the second account, call
donateonDonorwith 1 ETH. Then callgetTopContributorsonClassFund: the contributor is theDonorcontract, not you. - Approve: as the treasurer, call
approveExpensewith the third account’s address and1000000000000000000(1 ether in wei). - Withdraw: from the third account, call
withdraw, thenwithdrawagain. The second one reverts withNothingToWithdraw.
Build: the canteen
MealCredits is a small token: it keeps every student’s credits, and students buy them with ETH, one credit for CREDIT_PRICE = 0.001 ether. Canteen is the shop: it sells meals for 10 credits each and sends the credits to the kitchen. Fill in the functions marked TODO, 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.
| TODO | Function | Like | Check |
|---|---|---|---|
| 1 | MealCredits.allow | ERC-20 approve | Check1Allow |
| 2 | MealCredits.spendFrom | ERC-20 transferFrom: who is who | Check2SpendFrom |
| 3 | Canteen.setKitchen | set once, with the inherited onlyOwner | Check3Kitchen |
| 4 | Canteen.buyMeal | the shop calling the token | Check4BuyMeal |
| 5 | MealCredits.buyCredits | payable: is msg.value exactly right? | Check5BuyCredits |
| last | wire it by hand, below | deploy in order, pay, approve, then buy | Check6Together |
After each TODO, open 2-exercise/checks/CanteenChecks.sol, deploy the check with the TODO’s number and call run(). If the call goes through, the TODO works; if it reverts, the message says what to fix. Check5BuyCredits needs 0.01 ether sent with run(); it sends back whatever it doesn’t spend. Check6Together deploys both contracts itself and runs whole purchases. These are real runs after TODO 1 and 2:
Check1Allow.run() → "TODO 1 works." Check2SpendFrom.run() → "TODO 2 works. Look for Spent in the log when you try it by hand." Check3Kitchen.run() → revert "TODO 3: setKitchen should store the kitchen" Check4BuyMeal.run() → revert "Finish TODO 3 first" Check5BuyCredits.run() → revert "TODO 5: buyCredits(3) should add 3 credits to the caller" Check6Together.run() → revert "Finish TODO 3 first"
Finally, wire it by hand:
- Deploy
MealCreditsfrom the first account. That account is the admin. - Deploy
Canteenwith the address ofMealCredits, then callsetKitchenwith the third account’s address. - As the second account, call
buyCredits(30)onMealCreditswith 0.03 ether. - Still as the second account, call
allowonMealCreditswith the canteen’s address and 30, thenbuyMeal()on the canteen. getCredits: the student has 20, the kitchen 10.
Finished early? Break it on purpose:
| Try | You will see |
|---|---|
buyMeal without allow | OverLimit, from inside MealCredits |
setKitchen a second time | AlreadySet |
setKitchen from the second account | NotOwner, from Owned |
buyCredits(3) with 0.002 ether | WrongPayment |
allow(canteen, 10), then buy two meals | the second one reverts |
Recommended reading and resources
- Solidity documentation (https://docs.soliditylang.org/en/latest/)
- Receive Ether and fallback functions (https://docs.soliditylang.org/en/latest/contracts.html#special-functions)
- Inheritance (https://docs.soliditylang.org/en/latest/contracts.html#inheritance)
- Abstract contracts, interfaces and libraries (https://docs.soliditylang.org/en/latest/contracts.html#abstract-contracts)
- Contract ABI specification (https://docs.soliditylang.org/en/latest/abi-spec.html)
- Inline assembly (https://docs.soliditylang.org/en/latest/assembly.html) and the list of Yul instructions (https://docs.soliditylang.org/en/latest/yul.html#evm-dialect)
- ERC-20 token standard (https://eips.ethereum.org/EIPS/eip-20)
- Proxy upgrade pattern, OpenZeppelin (https://docs.openzeppelin.com/upgrades-plugins/proxies)
- Solidity (Wake) VS Code extension (https://marketplace.visualstudio.com/items?itemName=AckeeBlockchain.tools-for-solidity)