← Back to the library
Web3Deep dive · 10 min

A contract upgrade corrupts state or breaks the proxy

“After an upgrade, balances read as nonsense, the owner is a random address, or an initializer reverts; in the worst case the proxy can no longer be upgraded at all.”

THE PRINCIPLE

Storage is a contract with the past. Upgrades may append to it; they may never reorder, retype, or remove what is already there.

FIRST MOVES

  1. Diff the storage layout of the old and new implementations. Inserting, reordering, retyping, or removing a variable, or changing the order of base contracts, shifts every slot after it.
  2. Run OpenZeppelin upgrades validation in CI (validateUpgrade in Hardhat, Foundry Upgrades, or npx @openzeppelin/upgrades-core validate with @custom:oz-upgrades-from) and treat a layout error as a blocker.
  3. Check the ERC-1967 slots on the proxy: implementation at 0x3608...2bbc and admin at 0xb531...6103. Confirm the proxy points where you think, and who can upgrade it.
  4. Replace constructors with initializers, call _disableInitializers() in the implementation's constructor, and run migrations with reinitializer(n) through upgradeToAndCall in the same transaction.
  5. For UUPS, keep _authorizeUpgrade with access control in every version; a version without a working upgrade function locks the proxy forever. Transparent proxies keep that logic in the proxy and its ProxyAdmin.

TOOLS: THEN → NOW

Storage gaps (uint256[50] __gap) in base contractsERC-7201 namespaced storage, used by OpenZeppelin Contracts Upgradeable 5.x; gaps still work if kept accurate
Manual review of upgrade diffsOpenZeppelin Upgrades plugins and CLI validating layouts, initializers, and unsafe opcodes
Transparent proxies by defaultBoth remain valid: UUPS is cheaper to deploy, Transparent keeps upgrade logic out of the implementation

PATTERN SNAPSHOT

/// @custom:oz-upgrades-from VaultV1
contract VaultV2 is UUPSUpgradeable, OwnableUpgradeable {
    /// @custom:storage-location erc7201:example.vault
    struct VaultStorage { uint256 totalShares; mapping(address => uint256) shares; uint16 feeBps; } // append only
    // keccak256(abi.encode(uint256(keccak256("example.vault")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant VAULT_STORAGE = 0xd1921ee58d28820c9487d4d5d3eec1942edd7f5897e909e18a400cd2422da100;

    function _vault() private pure returns (VaultStorage storage $) { assembly { $.slot := VAULT_STORAGE } }

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() { _disableInitializers(); }
    // Run via upgradeToAndCall so nobody can front-run the migration.
    function initializeV2(uint16 fee) external reinitializer(2) { _vault().feeBps = fee; }
    function _authorizeUpgrade(address) internal override onlyOwner {}
}

CLOSE THE AI. EXPLAIN THIS.

V2 adds a new variable above the existing ones in a base contract. What do the existing slots read afterwards, and which check would have caught it before deploy?

HOW IT WORKS UNDERNEATH

SOURCES

Guide reviewed