Go to course navigation

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

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:

UnitIn weiUsed for
1 wei1what contracts count in
1 gwei1,000,000,000 (109)gas prices
1 ether1,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:

OperationGas
Add two numbers, or read or write memory3
Read a constant or immutable3: the value is written into the code
Read storage, first time in a transaction2,100
Read storage again100
Write storage, zero to non-zero20,000 + 2,100
Change a constant or immutableimpossible: fixed in the code
Start any transaction21,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:

OperationNormallyInside unchecked { }
uint8: 255 + 1reverts0: wraps around
uint256: 0 - 1reverts115792…​639935: the largest number
arr[5] when the array has 3 itemsrevertsstill reverts
x / 0revertsstill reverts
for (uint8 i = 0; i ⇐ 255; ) { …​; unchecked { i++; } }reverts at 255 + 1loops 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 0

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

StepWhoCallsWhat happens in ClassFund
1classmatescontribute() with ETHthe ETH stays in the contract
2the treasurer, who deployed itapproveExpense(Carol, 1 ETH)notes down: we owe Carol 1 ETH
3Carolwithdraw()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 callThe contract getsGas fee you payYour wallet after
succeeds, fee 0.2 ETH1 ETH0.2 ETH5 - 1 - 0.2 = 3.8 ETH
reverts after 0.1 ETH of fee00.1 ETH5 - 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 writeIf it failsGas budget for the receiver’s codeUse it?
to.transfer(amount)revertsat most 2,300no
to.send(amount)returns falseat most 2,300no
to.call{value: amount}("")returns falseas much as it needsyes, 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 happensWhat runs
Alice presses Send in her wallet: 0.01 ETH to ClassFundClassFund’s receive()
a contract sends it: fund.call{value: 0.01 ether}("")ClassFund’s receive()
Alice calls contribute() with 0.01 ETHClassFund’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:

KindWhat it isWhy you use itIn our code
contractcode and storagethe thing you deploy and people callClassFund, Donor
abstract contracta parent, never deployed alonewrite shared code once, like an owner checkOwned
interfaceonly function names and typescall a contract without having its code; agree on a standard like ERC-20IClassFund
libraryhelper functions, no storagereuse the same calculations in many contractsPercent, 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:

contractabstract contractinterfacelibrary
Function bodiesallsome can be missingnoneall
State variablesyesyesnoconstants only
Constructoryesyesnono
Deployed on its ownyesnonoonly if it has public functions
Used withdeploy itisis, or to call othersimport, 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: IERC20 works 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 DonorInside ClassFund
msg.senderAliceDonor
msg.value1 ETH1 ETH, because Donor wrote {value: msg.value}
tx.originAliceAlice

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. Donor gets the address of ClassFund. This is always the best option: the address can be immutable, 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.

NameWhat it isHereMeaning
msg.senderglobal variablethe shopwho is calling: the spender
from1st parameteryouwhose tokens move
to2nd parameterthe shopwhere they go: here back to the spender
amount3rd parameter10how many
allowance[from][msg.sender]storage30 → 20what 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:

FunctionWhat 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 wei

The 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 sendThe contract hasWhat runs
a call to withdraw()a withdraw() functionwithdraw()
1 ETH, no function namedreceive()receive()
1 ETH, no function namedno receive(), but a payable fallback()fallback()
1 ETH, no function namedneithernothing: it reverts, the ETH stays with you
a call to a function it doesn’t havefallback()fallback()
a call to a function it doesn’t haveno 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:

calldelegatecallstaticcall
Whose code runsthe other contract’sthe other contract’sthe other contract’s
Whose storage changesthe other contract’sminenobody’s: read only
msg.sender insidememy callerme
Used fornormal calls, sending ETHproxies, librariescalling 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)BeforeAfter delegatecall increment()
uint256 counting56: the name doesn’t matter
address owner0x7099…​79C80x7099…​79C9: a different owner
bool openfalsetrue
the proxy’s implementation, if V1 forgets ProxyStorageV1’s addressV1’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 contractUse a proxy
Addressnew: every user and contract must switchstays the same
Datastays in the old contract: copy it over yourselfstays in the proxy
What you deploythe new versionthe 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:

  1. Deploy CounterV1. Only the code; nobody calls this address directly.
  2. Deploy the Proxy with V1’s address. It remembers: my code is at V1.
  3. Alice calls increment() on the proxy. The proxy has no such function, so its fallback() delegatecalls V1: count is 1.
  4. Deploy CounterV2. The new code, at a new address.
  5. The admin calls upgradeTo(V2). Now the proxy remembers: my code is at V2. count is still 1.
  6. Alice calls increment() on the same proxy address. V2’s code runs: count is 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:

StepProxy’s storageV1’s storageV2’s storage
1. deploy V1, then the Proxy with V1’s addressimplementation = V1, count = 0count = 0-
2. Alice calls increment() on the proxyimplementation = V1, count = 1count = 0-
3. deploy V2; the admin calls upgradeTo(V2)implementation = V2, count = 1count = 0count = 0
4. Alice calls increment() on the proxyimplementation = V2, count = 11count = 0count = 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:

FolderWhat is in itWhat you do
1-examples/Owned, IClassFund, ClassFund, Donor, Upgradeclick through ClassFund and Donor; Upgrade is shown in class
2-exercise/IMealCredits, MealCredits, Canteenfill in five TODOs
2-exercise/checks/CanteenChecksdeploy 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:

  1. Deploy ClassFund from the first account. That account is the treasurer.
  2. Deploy Donor with the address of ClassFund in the constructor field.
  3. Donate: from the second account, call donate on Donor with 1 ETH. Then call getTopContributors on ClassFund: the contributor is the Donor contract, not you.
  4. Approve: as the treasurer, call approveExpense with the third account’s address and 1000000000000000000 (1 ether in wei).
  5. Withdraw: from the third account, call withdraw, then withdraw again. The second one reverts with NothingToWithdraw.

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.

TODOFunctionLikeCheck
1MealCredits.allowERC-20 approveCheck1Allow
2MealCredits.spendFromERC-20 transferFrom: who is whoCheck2SpendFrom
3Canteen.setKitchenset once, with the inherited onlyOwnerCheck3Kitchen
4Canteen.buyMealthe shop calling the tokenCheck4BuyMeal
5MealCredits.buyCreditspayable: is msg.value exactly right?Check5BuyCredits
lastwire it by hand, belowdeploy in order, pay, approve, then buyCheck6Together

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:

  1. Deploy MealCredits from the first account. That account is the admin.
  2. Deploy Canteen with the address of MealCredits, then call setKitchen with the third account’s address.
  3. As the second account, call buyCredits(30) on MealCredits with 0.03 ether.
  4. Still as the second account, call allow on MealCredits with the canteen’s address and 30, then buyMeal() on the canteen.
  5. getCredits: the student has 20, the kitchen 10.

Finished early? Break it on purpose:

TryYou will see
buyMeal without allowOverLimit, from inside MealCredits
setKitchen a second timeAlreadySet
setKitchen from the second accountNotOwner, from Owned
buyCredits(3) with 0.002 etherWrongPayment
allow(canteen, 10), then buy two mealsthe second one reverts