Skip to content
LogoLogo

Upgrades and migrations

Upgradeability is built into the language. You don't hand-write proxy logic, reserve implementation slots, or add storage gaps.

Upgradeable contracts

Mark a file upgradeable, and give it an upgrade guard:

import "std/evm";
 
upgradeable
 
error NotOwner;
 
storage is record {
  owner : address;
}
 
upgrade guard is {
  assert(Evm.sender() = storage.owner, NotOwner);
}
 
external function claim() is {
  storage.owner := Evm.sender();
}

For an explicit contract, write contract Vault upgradeable is { ... }.

The compiler produces a UUPS implementation for an ERC-1967 proxy. It adds two entrypoints:

EntrypointBehavior
proxiableUUID()returns the ERC-1967 implementation slot, when called on the implementation itself
upgradeToAndCall(address, bytes)runs the upgrade guard, checks the new implementation, stores it, logs ERC-1967's Upgraded(address indexed implementation), then runs the migration encoded in bytes, if any

Both refuse what OpenZeppelin 5.1's UUPSUpgradeable refuses, with the same errors, so an upgrade can't point the proxy at code that can't upgrade it again:

CheckError
proxiableUUID called through a proxyUUPSUnauthorizedCallContext()
upgradeToAndCall called other than through an ERC-1967 proxy that points at this implementationUUPSUnauthorizedCallContext()
the new implementation's proxiableUUID() fails, as a contract without one does, or a proxy's doesERC1967InvalidImplementation(address)
it answers something other than the implementation slotUUPSUnsupportedProxiableUUID(bytes32)
value sent with no migration to take itERC1967NonPayable()
the migration failsthe migration's own revert data, or FailedCall() when it has none

upgradeToAndCall checks the call context first, then runs the guard, then asks the new implementation, as OpenZeppelin does. The implementation knows its own address because its creation code writes it into its code, as OpenZeppelin's __self immutable is.

The guard is required. An upgradeable contract without one, or a guard in a contract that is not upgradeable, is a compile error:

upgradeable
 
storage is record {
  n : uint;
}
 
external function go() is {
  skip
}
upgradeable contract requires an upgrade guard

The upgrade guard is the upgrade machinery's guard, with the same rules as the guards modules declare: it only fails. It can read storage, the call's context, and other contracts with query, but it can't write, emit, or defer, and it runs before upgradeToAndCall changes anything:

import "std/evm";
 
upgradeable
 
error NotOwner;
 
storage is record {
  owner : address;
  upgrades : uint;
}
 
upgrade guard is {
  assert(Evm.sender() = storage.owner, NotOwner);
  storage.upgrades := storage.upgrades + 1;
}
 
external function claim() is {
  storage.owner := Evm.sender();
}
the upgrade guard writes storage; a guard only reads, and reverts or lets the call through

Count upgrades in a migration instead, which runs inside the upgrade and may write.

When a function already makes the check, name it: upgrade guard is f; is a guard whose body calls f, as guard M.g is f; is. f returns nothing and takes no parameters, and the guard raises what f does:

import "std/evm";
import "std/access/ownable";
 
upgradeable
 
use Ownable;
 
storage is record {
  n : uint;
}
 
upgrade guard is Ownable.checkOwner;
 
external function own() raises OwnableInvalidOwner is {
  Ownable.init(Evm.sender());
}

Other proxy designs, such as a beacon or a proxy whose admin lives outside the implementation, can be written with fallback delegates to. upgradeable is still the way to make a contract's own code upgradeable: its guard, migrations, and layout checks come with it.

Deploying

Deploy the implementation, then deploy a standard ERC-1967 proxy, such as OpenZeppelin's ERC1967Proxy, that points at it. Give the proxy's constructor a call to the contract's deploy as its data, and the proxy is initialized in the same transaction that creates it. Wallets and explorers treat the proxy as they treat any UUPS contract.

Migrations

A new version often needs to set up new state. A migrate function runs once, atomically, inside the upgrade that installs its version:

import "std/evm";
 
upgradeable
 
error NotOwner;
 
storage is record {
  owner : address;
}
 
storage settings at "vault.settings" is record {
  feeBps : uint;
  treasury : address;
}
 
upgrade guard is {
  assert(Evm.sender() = storage.owner, NotOwner);
}
 
migrate initSettings(const feeBps : uint; const treasury : address) is {
  settings.feeBps := feeBps;
  settings.treasury := treasury;
}
 
external view function feeBps() : uint is
  settings.feeBps
  • A migration can be called only from upgradeToAndCall. Called directly, even later in the upgrade's own transaction, it reverts: the upgrade sets a transient flag that the migration checks, and clears it once the migration returns.
  • It is not in the ABI as a normal function. Nobody can call it after the upgrade.
  • It cannot query or defer. An upgrade should only change your own state.
  • Every field starts at zero, so a migration is needed only to set non-zero values.
  • A migration can write immutables, which an upgradeable contract keeps in the proxy's storage. A field that a new version adds to a block is set this way.

lithe upgrade builds the upgrade transaction's calldata:

lithe upgrade 0xProxy vault.lithe --migrate initSettings --impl 0xNewImpl --args 30 0xTreasury
to: 0xProxy
data: 0x4f1ef286...

Storage compatibility

An upgrade must not move existing state. lithe upgrade-check compares two versions' storage layouts:

lithe upgrade-check v1.lithe v2.lithe
storage
  owner unchanged

settings
  fee unchanged
  paused unchanged
  treasury added

compatible

Reordering or retyping a field moves data, and is reported as incompatible:

settings
  fee changed -> paused
  paused changed -> fee

incompatible
ChangeResult
add a field at the end of a recordcompatible
add a new named domaincompatible
reorder, retype, or insert fields before existing onesincompatible

Named domains help here. Each domain sits at its own ERC-7201 root, so adding a domain, or growing one, never shifts another. Put new state in new domains, or at the end of existing ones.

upgrade-check reports; it does not block. Run it in CI and fail the build when it prints incompatible.

Contracts with facets

An upgradeable contract can run modules from their facets, as any contract can. The router is the implementation behind the proxy, and it keeps the upgrade guard, proxiableUUID, upgradeToAndCall, and every migration. Facets answer none of them, so a proxy can't be pointed at one. Upgrades work exactly as above. Marking a module delegated in a new version, or unmarking it, is an upgrade like any other, behind the same proxy, with the same storage. See Contract size limit.