Upgradeable Contracts skill for Codex
Upgrade proxies without corrupting storage. Codex first asks whether a contract should be upgradeable at all, then builds OpenZeppelin 5 UUPS or transparent proxies with disabled implementation initializers and ERC-7201 namespaced storage. Before any upgrade it runs OpenZeppelin validation plus a bundled storage-layout diff, rehearses upgradeToAndCall on a fork, and prepares a multisig or timelock transaction for you to approve.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- upgradeable-contracts Installs to ~/.agents/skills/upgradeable-contracts/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install upgradeable-contracts The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project upgradeable-contracts Installs to .agents/skills/upgradeable-contracts/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/upgradeable-contracts.zipunzip -o upgradeable-contracts.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) upgradeable-contracts PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $upgradeable-contracts, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and executes Solidity proxy upgrades safely - choosing UUPS, transparent or beacon proxies, OpenZeppelin 5.x upgradeable contracts with initializers, reinitializer and _disableInitializers, ERC-7201 namespaced storage, storage layout diffs before every upgrade, OpenZeppelin Foundry and Hardhat upgrade validation, and multisig plus timelock governance of upgrade rights. Use when making a contract upgradeable, writing V2 of a deployed implementation, reviewing an upgrade for storage collisions, fixing an initializer problem, or planning who controls upgrades.
Use it when
- A contract must be upgradeable or already sits behind a proxy
- You are writing a new implementation version for a live proxy
- An upgrade needs a storage-collision review
- Initializers, reinitializers or upgrade rights need fixing
Use something else when
- The contract can be immutable and you only need safe patterns: use solidity-contracts
- You want a full security assessment beyond upgrade safety: use smart-contract-audit
How to check the result
OpenZeppelin upgrade validation and the storage diff script both pass, and a fork test upgrades the live proxy, preserves key state reads and rejects a second reinitializer call.
The mistake it prevents
Inserting a new state variable above existing ones, which shifts every later slot and silently corrupts balances after the upgrade.
An upgrade is safe when the new implementation’s storage layout is a strict extension of the old one, every initializer can run exactly once, the implementation itself cannot be initialized or hijacked, the upgrade is validated by tooling and rehearsed on a fork, and upgrade rights sit behind a multisig and, for value-holding systems, a timelock.
First question: should it be upgradeable at all?
| Situation | Recommendation |
|---|---|
| Simple token or NFT with fixed rules | Immutable. Upgradeability adds trust assumptions users must accept |
| Protocol in early iteration with value at stake | Upgradeable, behind multisig + timelock, with a plan to reduce or renounce later |
| Many identical instances (vaults, pools) | Beacon proxy, or immutable clones with a migration path |
| Need to swap one strategy or module | Immutable core plus a settable module address (with timelock) |
| Contract over the 24 KB size limit | Split into libraries or modules first; a diamond (EIP-2535) only as a last resort |
Proxy pattern choice
| Pattern | Upgrade logic lives in | Pros | Cons |
|---|---|---|---|
| UUPS (default) | Implementation (_authorizeUpgrade) | Cheaper calls, one contract to deploy per proxy | A V2 without UUPSUpgradeable bricks upgrades forever |
| Transparent | Proxy + ProxyAdmin (auto-created in OZ 5.x) | Upgrade logic cannot be forgotten in V2 | Extra admin contract, slightly more gas |
| Beacon | Beacon contract shared by many proxies | Upgrade N proxies in one tx | All instances change together |
Inspect first
- Read
AGENTS.md,foundry.tomlor Hardhat config, and installed OZ packages:@openzeppelin/contracts-upgradeablemust be the same major and minor as@openzeppelin/contracts. - For an existing deployment, collect: proxy address, current implementation
(
cast implementation <proxy>or read the ERC-1967 slot), admin/owner, the exact source and compiler settings of the current implementation (verified source on the explorer). - Find the previous deployment’s build info or the V1 source at its release tag. Layout
comparisons need the deployed version, not whatever V1 looks like on
maintoday.
Writing an upgradeable contract (OZ 5.x, UUPS)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
contract VaultV1 is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable {
/// @custom:storage-location erc7201:example.vault
struct VaultStorage {
mapping(address => uint256) balanceOf;
uint256 totalDeposits;
}
// keccak256(abi.encode(uint256(keccak256("example.vault")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant VAULT_STORAGE = 0x...; // compute once and paste; test it
function _s() private pure returns (VaultStorage storage $) {
assembly { $.slot := VAULT_STORAGE }
}
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() { _disableInitializers(); }
function initialize(address owner_) external initializer {
__Ownable_init(owner_);
__Ownable2Step_init();
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
constructor() { _disableInitializers(); }stops anyone initializing the implementation contract directly.- No constructors with logic, no
immutablestate that differs per proxy, no initial values on state variable declarations (they run in the implementation’s constructor, not the proxy). - Use ERC-7201 namespaced storage for your own state (as OZ 5.x does internally). It removes
the need for
__gaparrays and makes adding fields safe within the struct (append only). - Add a test asserting the constant equals the formula, so a typo cannot land storage at a random slot.
Writing V2
/// @custom:oz-upgrades-from VaultV1
contract VaultV2 is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable {
/// @custom:storage-location erc7201:example.vault
struct VaultStorage {
mapping(address => uint256) balanceOf;
uint256 totalDeposits;
uint256 withdrawFeeBps; // appended: safe
}
// ... same slot constant and accessor ...
function initializeV2(uint256 feeBps) external reinitializer(2) onlyOwner {
_s().withdrawFeeBps = feeBps;
}
}
Storage rules, for linear and namespaced layouts alike:
- Never remove, reorder or change the type of an existing variable or struct field.
- Only append new variables at the end (or at the end of the namespaced struct).
- Never change the inheritance order of contracts that hold linear storage.
- Renaming is layout-safe but confuses everyone; avoid it.
- Keep
UUPSUpgradeableand_authorizeUpgradein every UUPS version.
Validate before every upgrade
Primary check with OpenZeppelin tooling:
- Foundry:
openzeppelin-foundry-upgrades(Upgrades.validateUpgrade("VaultV2.sol", opts),Upgrades.upgradeProxy(proxy, "VaultV2.sol", data)). It requires infoundry.toml:ffi = true,ast = true,build_info = true,extra_output = ["storageLayout"], and aforge cleanbefore running. It shells out to the@openzeppelin/upgrades-coreCLI via npx, which needs network the first time. - Hardhat:
@openzeppelin/hardhat-upgrades(upgrades.validateUpgrade,upgrades.upgradeProxy). Check that the plugin version supports your Hardhat major.
Independent check for linear storage (catches inserted, moved and retyped variables, and gap misuse):
forge inspect src/VaultV1.sol:VaultV1 storageLayout --json > old.json
forge inspect src/VaultV2.sol:VaultV2 storageLayout --json > new.json
python3 scripts/storage_layout_diff.py old.json new.json # exit 1 = incompatible
Namespaced (ERC-7201) structs do not appear in that output; rely on the OZ validator plus a fork test for them.
Fork rehearsal
function test_UpgradeToV2_PreservesState() public {
vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK);
VaultV1 proxy = VaultV1(PROXY);
uint256 before = proxy.balanceOf(KNOWN_USER);
address owner = proxy.owner();
VaultV2 impl = new VaultV2();
vm.prank(owner);
proxy.upgradeToAndCall(address(impl), abi.encodeCall(VaultV2.initializeV2, (30)));
assertEq(VaultV2(PROXY).balanceOf(KNOWN_USER), before, "state survived");
assertEq(VaultV2(PROXY).withdrawFeeBps(), 30);
vm.prank(owner); // even the owner cannot re-run it
vm.expectRevert(Initializable.InvalidInitialization.selector);
VaultV2(PROXY).initializeV2(50);
}
In OZ 5.x, UUPS proxies only expose upgradeToAndCall (pass empty bytes for no call).
Executing an upgrade
- [ ] 1. OZ validation passes; storage diff clean; full test suite green on V2
- [ ] 2. Fork rehearsal against the live proxy at a recent block passes
- [ ] 3. Deploy the new implementation; verify its source on the explorer
- [ ] 4. Prepare the upgrade call (upgradeToAndCall with reinitializer data) as a Safe
transaction or timelock proposal; record calldata in the repo
- [ ] 5. User confirms network, proxy, new implementation address and calldata
- [ ] 6. Signers execute; after the timelock delay, execute
- [ ] 7. Post-checks: implementation slot updated, key state reads unchanged, events emitted
Never execute steps 3 to 6 on mainnet without explicit user confirmation at each step. Deploying the implementation is harmless; the upgrade transaction is not.
Governance of upgrade rights
- Owner or admin is a multisig (Safe), never a deployer EOA after launch.
- Value-holding systems put a
TimelockControllerbetween the multisig and the proxy so users can exit before an upgrade they disagree with lands. - Document the upgrade authority in the README and docs; users and auditors will ask.
Gotchas
- A forgotten
initializecall after deploying a proxy lets anyone initialize it and take ownership. Deploy and initialize in the same transaction (pass init data to the proxy constructor or use the upgrades plugin). selfdestructordelegatecallto arbitrary addresses in an implementation can brick every proxy that uses it.- Upgrading from OZ 4.x to 5.x in place is not supported: 5.x moved to namespaced storage, so the layout is incompatible.
- Changing compiler optimizer settings changes bytecode but not layout; still validate.
immutablevariables live in the implementation bytecode: every proxy sees the same value. Allowed only for values that are truly global, with the unsafe-allow annotation.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Upgradeable Contracts"
short_description: "Upgrade proxies without corrupting storage"
default_prompt: "$upgradeable-contracts Write VaultV2 with a withdrawal fee and check it is storage-compatible with V1" scripts/storage_layout_diff.py
#!/usr/bin/env python3
"""Check that a new Solidity storage layout is upgrade-compatible with an old one.
Usage:
forge inspect src/VaultV1.sol:VaultV1 storageLayout --json > old.json
forge inspect src/VaultV2.sol:VaultV2 storageLayout --json > new.json
python3 storage_layout_diff.py old.json new.json
Accepts solc storageLayout JSON ({"storage": [...], "types": {...}}) as printed
by forge inspect or found in solc/Hardhat build-info output.
Rules checked for linear (non-namespaced) storage:
ERROR an old variable was removed, or its name now sits at another slot/offset
ERROR an old variable changed type or byte size at its position
ERROR a new variable was inserted inside the old layout (outside a shrunk __gap)
ERROR a __gap shrank by a different number of slots than the variables placed in it
WARN an old variable was renamed (same slot, offset and type)
OK new variables appended after the old layout, or placed into a shrunk __gap
ERC-7201 namespaced storage does not appear in this output; validate it with
OpenZeppelin's upgrade tooling instead.
Exit codes: 0 compatible, 1 incompatible, 2 usage or parse error.
"""
from __future__ import annotations
import json
import re
import sys
ARRAY_LEN = re.compile(r"\[(\d+)\]$")
def load(path: str):
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"error: cannot read {path}: {exc}", file=sys.stderr)
sys.exit(2)
if "storageLayout" in data:
data = data["storageLayout"]
if "storage" not in data or "types" not in data:
print(f"error: {path} is not a storageLayout JSON", file=sys.stderr)
sys.exit(2)
types = data["types"] or {}
entries = []
for s in data["storage"]:
t = types.get(s["type"], {})
entries.append({
"label": s["label"],
"slot": int(s["slot"]),
"offset": int(s["offset"]),
"type": t.get("label", s["type"]),
"bytes": int(t.get("numberOfBytes", "0")),
})
return entries
def slots_used(e) -> int:
return max(1, (e["offset"] + e["bytes"] + 31) // 32)
def is_gap(e) -> bool:
return e["label"].startswith("__gap") and bool(ARRAY_LEN.search(e["type"]))
def main() -> int:
if len(sys.argv) != 3:
print(__doc__, file=sys.stderr)
return 2
old, new = load(sys.argv[1]), load(sys.argv[2])
errors, warns = [], []
new_at = {(e["slot"], e["offset"]): e for e in new}
new_by_label = {e["label"]: e for e in new}
gap_regions = [] # slot ranges freed by a shrunk gap
for o in old:
n = new_at.get((o["slot"], o["offset"]))
where = f"slot {o['slot']} offset {o['offset']}"
if is_gap(o):
old_end = o["slot"] + slots_used(o)
new_gap = next((e for e in new if is_gap(e) and e["slot"] + slots_used(e) == old_end), None)
if new_gap is None:
errors.append(f"{o['label']} ({where}): gap removed or its end moved; layout after it shifts")
continue
freed = (o["slot"], new_gap["slot"])
used = sum(slots_used(e) for e in new if freed[0] <= e["slot"] < freed[1])
if used != freed[1] - freed[0]:
errors.append(f"{o['label']}: shrank by {freed[1] - freed[0]} slots but new variables use {used}")
gap_regions.append(freed)
continue
moved = new_by_label.get(o["label"])
if moved is not None and (moved["slot"], moved["offset"]) != (o["slot"], o["offset"]):
errors.append(f"'{o['label']}' moved from {where} to slot {moved['slot']} "
f"offset {moved['offset']} (something was inserted or reordered before it)")
continue
if n is None:
errors.append(f"'{o['label']}' ({o['type']}) at {where} was removed")
continue
if n["type"] != o["type"] or n["bytes"] != o["bytes"]:
errors.append(f"'{o['label']}' at {where} changed type {o['type']} -> {n['type']}")
elif n["label"] != o["label"]:
warns.append(f"'{o['label']}' at {where} renamed to '{n['label']}' (safe, check intent)")
old_keys = {(e["slot"], e["offset"]) for e in old}
old_end = max((e["slot"] + slots_used(e) for e in old), default=0)
for n in new:
if (n["slot"], n["offset"]) in old_keys or is_gap(n):
continue
if any(a <= n["slot"] < b for a, b in gap_regions):
continue
if n["slot"] < old_end:
errors.append(f"'{n['label']}' inserted at slot {n['slot']} inside the old layout")
added = [n["label"] for n in new if n["slot"] >= old_end and not is_gap(n)]
for w in warns:
print(f"WARN {w}")
for e in errors:
print(f"ERROR {e}")
print(f"\nold vars: {len(old)}, new vars: {len(new)}, appended: {', '.join(added) or 'none'}")
print("result: " + ("INCOMPATIBLE" if errors else "compatible"))
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.