Skip to main content

Type a word from a question or an article title.

3 · Under the hood

Proxies and upgradeable contracts - delegatecall, UUPS and the price of flexibility

How proxy contracts work, what delegatecall does, how Transparent and UUPS differ, why EIP-1967 slots are needed and why Coin-Trust chose an immutable contract.

5 min readUpdated:
In this article

The code of a deployed contract cannot be changed. That is a strength (the rules cannot be swapped) and a weakness (a found bug cannot be fixed). To combine a stable address with the ability to upgrade, proxies were invented. But this flexibility is costly: it adds trust in whoever can upgrade. This article explains the mechanics and the trade-off. The basics of contracts are in the article what a smart contract is.

The idea: one address, swappable logic

Instead of one contract there are two:

  • The proxy - a thin contract at a permanent address. It holds the data and can do nothing itself.
  • The implementation - a contract with the code (logic). It can be replaced by a new version.

The user calls the proxy. It forwards the call to the current implementation but runs its code on its own data. To upgrade, the owner changes the implementation address in the proxy: the address and data stay the same, while the logic is new.

delegatecall: someone else's code on my data

The key operation is delegatecall. An ordinary call (call) runs another contract's code in its context. delegatecall runs someone else's code in the caller's context: the same msg.sender, the same storage, the same balance.

solidity
// simplified
fallback() external payable {
    address impl = _implementation();
    assembly {
        calldatacopy(0, 0, calldatasize())
        let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch ok case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) }
    }
}

Any call not found in the proxy itself (fallback) is forwarded to the implementation along with the data (the call format). This works at the level of the EVM.

Where to store the implementation address

The implementation address must also live in the proxy's storage. If put in slot 0, it would collide with the implementation's variables. The EIP-1967 standard defines a "random" slot:

text
slot = keccak256("eip1967.proxy.implementation") - 1
     = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

The slot is chosen via a hash, so it does not overlap with ordinary variables. Block explorers know this slot and show "Read as Proxy".

Kinds of proxy

SchemeWhere the upgrade logic livesPeculiarity
Transparentin the proxythe admin cannot call the logic, others cannot administer
UUPSin the implementationcheaper per call, but a new version must contain the upgrade function
Beaconat the "beacon"one beacon upgrades many proxies at once
Minimal proxy (EIP-1167)no upgradecheap clones of one implementation

Initialisers instead of a constructor

A constructor runs when the implementation is deployed and writes data to its storage, not the proxy's. So upgradeable contracts use an ordinary function initialize() called once through the proxy instead.

Storage layout compatibility

A new implementation must preserve the order and types of the old variables. If version 2 inserts a new variable in the middle, all the following shift and read other people's data.

solidity
// v1
address owner;      // slot 0
uint256 total;      // slot 1

// v2 - a bug: a field inserted in the middle
address owner;      // slot 0
uint256 fee;        // slot 1 - now reads the old total!
uint256 total;      // slot 2

The rule: new variables only at the end (or use reserved "empty" slots). Layout checks are automated by tools.

The main risk: trust in whoever upgrades

Proxies turn "code is law" into "code, until the administrator decides otherwise". Whoever can change the implementation can, in principle, replace the logic with a malicious one and take the funds. So trust is placed not only in the code but also in governance. Measures:

  • the admin is a multisig, not one key;
  • a timelock: an upgrade applies after a delay and users have time to exit;
  • public audits of every new version (audits);
  • over time - renouncing the upgrade right or handing governance to the community.

Why Coin-Trust is immutable

The escrow contract is deployed without a proxy: its code cannot be changed by anyone, including the developers. This is a deliberate choice for a service that holds other people's money:

  • users need not trust the administrator's future decisions;
  • the source code is verified once and stays that way forever;
  • there is no class of vulnerabilities with initialisers and storage layout.

The price of the choice: a bug cannot be fixed by an upgrade. So the contract was made as simple as possible and is covered by tests (including fuzzing), and the owner has only limited administrative rights (owner rights). A new version would mean a new contract at a new address, while deals in the old one would finish by the old rules (addresses via CREATE2).

How to recognise a proxy

  1. The explorer shows a "Read as Proxy" tab or an implementation address.
  2. The code contains delegatecall, implementation, upgradeTo.
  3. The contract's readable functions are tiny, while the main logic is at another address.

Before using someone else's project, find out who holds the upgrade right and whether there is a timelock.

Find out how unique deterministic addresses are built: CREATE2. Or how large sets of data are verified: Merkle trees.

Mentioned in

Ready to try it?

Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.