Skip to content
Boomspot
  • Home
Loading...
Boomspot

Daily tech news, software development coverage, Apple reporting, and the gear behind modern music making.

TwitterLinkedIn

Browse

  • Categories
  • Tags
  • Authors

Company

  • About
  • Contact

Legal

  • Privacy Policy
  • Terms of Service
  • Unsubscribe

© 2026 Boomspot. All rights reserved.

Built by Boomspot
Updated hourly

AI Content Disclosure: Articles on Boomspot are researched, written, and edited with the assistance of advanced AI systems. We combine software-assisted research with editorial oversight to deliver useful, accurate, and practical technical and music production content. Learn more about our editorial approach.

Browse by Category

Technology656Coding166Linux41SEO36Music Production27Studio Gear22Apple Rumors11

Popular Posts

Shotcut vs Kdenlive: Best Free Linux Video Editor?

Shotcut vs Kdenlive: Best Free Linux Video Editor?

6 min read
Best Free DAW for Beginner Beatmakers: Full Comparison

Best Free DAW for Beginner Beatmakers: Full Comparison

6 min read
Discover's 'Dive Deeper' AI Test: A Publisher Checklist

Discover's 'Dive Deeper' AI Test: A Publisher Checklist

4 min read
How to Disable Firefox's Nova Redesign on Linux

How to Disable Firefox's Nova Redesign on Linux

5 min read
How to Digitize Vinyl Records With a USB Turntable

How to Digitize Vinyl Records With a USB Turntable

5 min read

Recent Posts

Best WebDAV Mount Tool for Windows: Pick and Test One

Best WebDAV Mount Tool for Windows: Pick and Test One

Oct 6, 2026•9 min
Minimal Home Studio Gear List: How to Pick Every Item

Minimal Home Studio Gear List: How to Pick Every Item

Oct 6, 2026•8 min
ROCm or Vulkan for llama.cpp on AMD GPUs? How to Decide

ROCm or Vulkan for llama.cpp on AMD GPUs? How to Decide

Oct 6, 2026•8 min
How to Check Your AMD P-State Driver Mode on Linux

How to Check Your AMD P-State Driver Mode on Linux

Oct 6, 2026•7 min
How to Compare Granular Synths in Your DAW: 6-Step Test

How to Compare Granular Synths in Your DAW: 6-Step Test

Oct 6, 2026•9 min
  1. Home
  2. Coding
  3. Check Storage Layout Before Upgrading a Proxy Contract
coding8 min read

Check Storage Layout Before Upgrading a Proxy Contract

One misplaced state variable can make every user balance read as zero after an upgrade. Here is how to catch it with a layout diff in Hardhat and CI before deploying.

S

Staff

October 6, 2026

Reviewed byDorian

Check Storage Layout Before Upgrading a Proxy Contract

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

  1. Layout diff: Run validateUpgrade or prepareUpgrade against the live implementation, not just V1 source, and commit the manifest.
  2. Gaps: Confirm every new variable shrank the nearest __gap by its slot count, and that new variables sit at the end of their own contract.
  3. Initializer: Use reinitializer(n) for new state, keep _disableInitializers() in the constructor, and never reorder or edit the original initialize.
  4. 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.
  5. 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.

Tags

Software DevelopmentCoding Best PracticesDeveloper ToolsCoding TutorialsOpen Source

Keep reading

Unlocking ChatGPT Developer Mode: Full MCP Client Access
Coding•4 min read

Unlocking ChatGPT Developer Mode: Full MCP Client Access

Unlock the power of ChatGPT Developer Mode with full MCP client access. Discover how to enhance your coding projects and streamline development.

Sep 11, 2025

MCP Implementation at HubSpot: Elevating CRM with Context
Coding•4 min read

MCP Implementation at HubSpot: Elevating CRM with Context

Explore HubSpot's transformative MCP implementation for their CRM, detailing key strategies, challenges, and best practices for developers.

Sep 20, 2025

Your Guide to GitHub Universe 2025: Schedule Launched!
Coding•3 min read

Your Guide to GitHub Universe 2025: Schedule Launched!

Get ready for GitHub Universe 2025! Check out the schedule, create your personalized agenda, and sign up for mentoring sessions. Join us for an exciting experience!

Sep 13, 2025

More stories for your next project

Get tech, coding, and music production updates in your inbox.

Unsubscribe anytime.