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
- 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.
- 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.
- 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.
- Replace constructors with initializers, call _disableInitializers() in the implementation's constructor, and run migrations with reinitializer(n) through upgradeToAndCall in the same transaction.
- 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
RELATED SYMPTOMS
SOURCES
Guide reviewed