One misplaced state variable can make every user balance read as zero after an upgrade. The transaction succeeds, the proxy points at the new code, and nothing reverts. You find out when users complain.
This guide shows how to catch that mistake before deployment. You will build a minimal V1 and V2 vault, run a layout-compatibility check with the OpenZeppelin Upgrades plugin, watch a deliberately broken V2 fail, and wire the check into CI.
The hook is a dev.to post dated 5 Oct 2026 (https://dev.to/dannydoes_2abdf9c/protocol-upgrade-compatibility-review-eigencloud-2e50). It claims storage-slot collision risks in a large protocol and recommends layout diffs in CI. The page is unverified, still contains a "[Your Firm]" placeholder, and says an AI agent wrote it autonomously, so it reads as templated, auto-posted content. Treat it as a reminder to run the check, not as evidence about any protocol.
Status of everything below: none of the code or output in this article has been executed. The expected outputs follow the tooling's documented behavior, and the exact wording may differ in your versions. Run everything yourself before you trust it.
Why a new variable in the wrong place corrupts data
A proxy holds all state. The implementation supplies only code. When V2 code reads a variable, it reads the storage slot the compiler assigned from V2's declaration order, not V1's.
Solidity assigns slots in declaration order. Suppose V1 declares totalDeposits (slot 0) and balances (slot 1). The mapping stores each user's value at a location derived from the key and slot 1. If V2 inserts feeRate first, feeRate lands on slot 0, totalDeposits moves to slot 1, and balances moves to slot 2.
This result comes from the slot rules, not from a run. feeRate reads the old totalDeposits number, and totalDeposits reads an empty slot. balances now looks up keys under base slot 2, where nothing was ever written. Every balance appears to be zero, while the real data still sits under base slot 1, unreachable.
Step 1: Pin your toolchain
Use exact versions so a check that passes today still passes in CI next month. These pins are examples for this walkthrough and are not verified as current. Check the npm registry and bump them deliberately.
{
"devDependencies": {
"hardhat": "2.22.10",
"@nomicfoundation/hardhat-ethers": "3.0.8",
"ethers": "6.13.2",
"@openzeppelin/hardhat-upgrades": "3.2.1",
"@openzeppelin/contracts-upgradeable": "5.0.2"
}
}
Set Solidity to 0.8.24 in hardhat.config.js and register the plugin with require("@openzeppelin/hardhat-upgrades");. Commit your lockfile and install with npm ci, not npm install.
Step 2: Write the V1 contract
This is a UUPS vault (unexecuted). The __gap reserves 48 slots for future variables. The constructor locks the implementation so nobody can initialize it directly.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract VaultV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public totalDeposits;
mapping(address => uint256) public balances;
uint256[48] private __gap;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address owner_) external initializer {
__Ownable_init(owner_);
}
function deposit() external payable {
balances[msg.sender] += msg.value;
totalDeposits += msg.value;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
In the 5.x line of OpenZeppelin's upgradeable contracts, the library's own parent contracts use namespaced storage, so they do not consume linear slots. Your own variables still do, and the gap remains your responsibility.
Step 3: Write a safe V2
Append the new variable at the end of your own state, and shrink the gap by the same number of slots. One new uint256 means 48 becomes 47, so the total footprint stays identical. For more on this, see more on google play organization vs personal account: which to pick.
contract VaultV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public totalDeposits;
mapping(address => uint256) public balances;
uint256 public feeRate; // new, appended
uint256[47] private __gap; // was 48
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initializeV2(uint256 feeRate_) external reinitializer(2) {
feeRate = feeRate_;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
totalDeposits += msg.value;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
The reinitializer(2) modifier matters. A plain initializer cannot run again on a proxy that has already initialized, so the new state would never get set.
Gaps matter most in base contracts that other contracts inherit. If a base contract adds a variable without shrinking its gap, every child variable shifts. In a leaf contract like this one, the gap is a convention that keeps the footprint predictable and signals your intent to the plugin.
Step 4: Add the validation script
Create scripts/validate-upgrade.js (unexecuted). It compares V1's layout to the candidate's and exits non-zero on failure.
const { ethers, upgrades } = require("hardhat");
async function main() {
const candidate = process.env.NEW_IMPL || "VaultV2";
const V1 = await ethers.getContractFactory("VaultV1");
const V2 = await ethers.getContractFactory(candidate);
await upgrades.validateUpgrade(V1, V2, { kind: "uups" });
console.log("Layout check passed: VaultV1 -> " + candidate + " (uups)");
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
For a Transparent proxy, change kind to "transparent". The layout rules are the same. UUPS additionally gets checked for a working upgrade function, which Transparent proxies do not need. We cover related ground in our guide to how to find good first issues for hacktoberfest 2026.
In a real release, compare against what is actually deployed. Commit the plugin's .openzeppelin/ manifest for each network. Then use upgrades.prepareUpgrade(proxyAddress, V2) so the reference is the live implementation, not whatever V1 source sits in your working tree.
Step 5: Run it and read the output
For the safe pair, run npx hardhat run scripts/validate-upgrade.js. Expected output (unexecuted):
Layout check passed: VaultV1 -> VaultV2 (uups)
The plugin itself should print nothing on success. The line above comes from your own console.log, and the exit code is 0.
Step 6: Break it on purpose
Add VaultV2Broken. It is identical to V2 except that feeRate comes first and the gap is untouched:
contract VaultV2Broken is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public feeRate; // inserted before existing state
uint256 public totalDeposits;
mapping(address => uint256) public balances;
uint256[48] private __gap;
// constructor, initializeV2, deposit, _authorizeUpgrade as in VaultV2
}
Run NEW_IMPL=VaultV2Broken npx hardhat run scripts/validate-upgrade.js. The plugin should reject the upgrade with output along these lines (unexecuted, wording approximate):
Error: New storage layout is incompatible
VaultV2Broken: Layout changed for `totalDeposits` (uint256 -> uint256)
Renamed or moved: expected slot 0, found slot 1
> Insert new variables at the end of the contract and reduce __gap accordingly
The script exits with code 1. If your output differs but still names the moved variable and refuses to proceed, the check is working. If it prints your success line, your setup is wrong. The most likely cause is that the script compares a contract to itself.
Step 7: Make CI fail the build
CI needs only a non-zero exit code. This GitHub Actions job (unexecuted) also runs the broken contract and requires it to fail, which proves the check can still catch something.
Also read: measure core web vitals real user data with web-vitals — background
name: upgrade-safety
on: [pull_request]
jobs:
layout:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx hardhat compile
- run: npx hardhat run scripts/validate-upgrade.js
- run: "! NEW_IMPL=VaultV2Broken npx hardhat run scripts/validate-upgrade.js"
The last line inverts the exit code, so the job goes red if the broken contract ever passes. If VaultV2Broken lives only in a test folder that production builds exclude, remove that step from your release pipeline.
Foundry users can get the same check from OpenZeppelin's Foundry upgrades library, which exposes a validateUpgrade call. It needs FFI enabled and build-info output. The syntax is not shown or verified here, so check the library's README for the current form.
Verification step
A passing check proves layout compatibility, not correct logic. After it passes, rehearse on a fork. Deploy V1 behind a proxy, deposit from a few accounts, upgrade to V2, and call initializeV2. Then assert that balances and totalDeposits equal their pre-upgrade values. Run the same assertions against VaultV2Broken once to see them fail.
Pre-upgrade checklist
- Layout diff: Run
validateUpgradeorprepareUpgradeagainst the live implementation, not just V1 source, and commit the manifest. - Gaps: Confirm every new variable shrank the nearest
__gapby its slot count, and that new variables sit at the end of their own contract. - Initializer: Use
reinitializer(n)for new state, keep_disableInitializers()in the constructor, and never reorder or edit the originalinitialize. - Admin and timelock: Confirm who can call the upgrade, whether that is a multisig or a timelock, and that the key is not a single hot wallet.
- Fork rehearsal: Replay the upgrade against a fork of the live network and assert balances, totals, and ownership before and after.
What to expect next
Inheritance is a likely source of early friction. If a base contract you do not own changes between dependency bumps, the diff will flag it. Treat that as the tool doing its job: pin the version and read the changelog before bumping.
Once that works, add invariant tests for balances across an upgrade. Layout checks catch slot movement but not logic errors.



