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.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- upgradeable-contracts

Installs to ~/.agents/skills/upgradeable-contracts/ for every project on this machine.

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

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.

~/.agents/skills/upgradeable-contracts/SKILL.md

--- name: upgradeable-contracts description: Designs and executes Solidity proxy upgrades safely - choosing UUPS, transparent or beacon proxies, OpenZeppelin 5.x upgradeable contracts with… license: MIT ---

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?

SituationRecommendation
Simple token or NFT with fixed rulesImmutable. Upgradeability adds trust assumptions users must accept
Protocol in early iteration with value at stakeUpgradeable, 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 moduleImmutable core plus a settable module address (with timelock)
Contract over the 24 KB size limitSplit into libraries or modules first; a diamond (EIP-2535) only as a last resort

Proxy pattern choice

PatternUpgrade logic lives inProsCons
UUPS (default)Implementation (_authorizeUpgrade)Cheaper calls, one contract to deploy per proxyA V2 without UUPSUpgradeable bricks upgrades forever
TransparentProxy + ProxyAdmin (auto-created in OZ 5.x)Upgrade logic cannot be forgotten in V2Extra admin contract, slightly more gas
BeaconBeacon contract shared by many proxiesUpgrade N proxies in one txAll instances change together

Inspect first

  1. Read AGENTS.md, foundry.toml or Hardhat config, and installed OZ packages: @openzeppelin/contracts-upgradeable must be the same major and minor as @openzeppelin/contracts.
  2. 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).
  3. 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 main today.

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 immutable state 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 __gap arrays 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 UUPSUpgradeable and _authorizeUpgrade in 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 in foundry.toml: ffi = true, ast = true, build_info = true, extra_output = ["storageLayout"], and a forge clean before running. It shells out to the @openzeppelin/upgrades-core CLI 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 TimelockController between 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 initialize call 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).
  • selfdestruct or delegatecall to 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.
  • immutable variables 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.yamlgenerated from this page

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.py127 lines, 5.0 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem