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:
| Entrypoint | Behavior |
|---|---|
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:
| Check | Error |
|---|---|
proxiableUUID called through a proxy | UUPSUnauthorizedCallContext() |
upgradeToAndCall called other than through an ERC-1967 proxy that points at this implementation | UUPSUnauthorizedCallContext() |
the new implementation's proxiableUUID() fails, as a contract without one does, or a proxy's does | ERC1967InvalidImplementation(address) |
| it answers something other than the implementation slot | UUPSUnsupportedProxiableUUID(bytes32) |
| value sent with no migration to take it | ERC1967NonPayable() |
| the migration fails | the 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 guardThe 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 throughCount 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
queryordefer. 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 0xTreasuryto: 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.lithestorage
owner unchanged
settings
fee unchanged
paused unchanged
treasury added
compatibleReordering or retyping a field moves data, and is reported as incompatible:
settings
fee changed -> paused
paused changed -> fee
incompatible| Change | Result |
|---|---|
| add a field at the end of a record | compatible |
| add a new named domain | compatible |
| reorder, retype, or insert fields before existing ones | incompatible |
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.

