DEV Community

Meriç Cintosun
Meriç Cintosun

Posted on • Originally published at mericcintosun.com

Proxy Contracts and Upgradeability Risks: Storage Collision Analysis and Testing Strategies

Immutable code has historically been a feature of blockchain development, not a bug. Once deployed to mainnet, a smart contract cannot be modified. This immutability guarantees that users interact with exactly what they audited, but it also creates a harsh reality: mistakes are permanent, and new functionality cannot be added. The proxy pattern emerged to solve this problem by decoupling logic from state, allowing developers to upgrade contract behavior without losing historical data or breaking user integrations.

However, proxy patterns introduce their own class of vulnerabilities. Storage layout collisions between proxy contracts and their implementations, version drift across upgrade paths, and subtle state corruption bugs can emerge only after multiple upgrades in production. These risks are not theoretical—they have caused millions in losses across deployed protocols. The difference between a successful upgrade and a catastrophic failure often comes down to whether storage layout was analyzed correctly before deployment and whether the upgrade path was tested systematically in CI/CD.

This article examines the mechanics of proxy-based upgradeability, identifies where storage collisions occur, and demonstrates how to test upgrade paths before they reach mainnet. We focus on transparent proxy patterns and UUPS (Universal Upgradeable Proxy Standard) implementations, the two most widely deployed approaches in production systems.

Understanding Proxy Architecture and Storage Layout

The proxy pattern works by separating two contracts: a proxy that receives all calls and delegates them to an implementation, and an implementation contract that contains the actual business logic. The proxy maintains its own storage, while the implementation contract provides the code that modifies it. When an upgrade occurs, the proxy's implementation pointer is updated to reference a new implementation contract, but the proxy's storage persists unchanged.

This separation creates the core design challenge: both the proxy and the implementation need storage, and they must use the same storage layout. The proxy typically stores its admin address and the address of the current implementation contract. The implementation contract stores application state. When the implementation changes, the new implementation must be able to read and write to the proxy's storage exactly as the old implementation did. Any mismatch in slot allocation causes data corruption.

Consider a simple example. A proxy contract might use storage slot 0 for its admin address. An implementation contract might declare its first state variable at slot 0, expecting that slot to be unoccupied. When the implementation tries to write to its state variable, it overwrites the admin address. The proxy becomes orphaned—no one can call its upgrade function anymore because the admin slot has been corrupted.

The Solidity compiler allocates storage slots sequentially starting from slot 0. A state variable declared first occupies slot 0, the next occupies slot 1, and so on. Structs and arrays follow specific packing rules. The vulnerability emerges because developers often do not account for the storage that the proxy itself consumes. They write implementations without knowledge of how much storage the proxy needs, or worse, they inherit from OpenZeppelin's Ownable or Upgradeable base classes in both the proxy and the implementation, effectively double-counting storage variables.

Transparent Proxies vs. UUPS: Storage Implications

Transparent proxies use two distinct storage layouts. The proxy contract stores admin and implementation addresses. The implementation contract stores application state. A key mechanism in transparent proxies is the fallback function: if the caller is the admin, the proxy intercepts the call and handles it directly (for upgrade operations). Otherwise, the proxy delegates the call to the implementation. This prevents admin functions from being shadowed by implementation functions with the same signature.

However, transparent proxies impose storage overhead in the implementation. If the implementation contract is also Ownable, developers sometimes inherit Ownable twice: once from a base contract and once indirectly through the proxy. This creates storage duplication. The implementation's first few slots are now occupied by proxy-related storage that the implementation never actually uses, but the Solidity compiler allocates them anyway.

UUPS proxies flip the responsibility. The proxy itself is minimal and contains no admin logic. The implementation contract imports a UUPS mixin that provides the upgrade mechanism. The implementation calls a delegatecall to itself to execute the upgrade, avoiding the double-inheritance trap. The trade-off is that the implementation now carries the upgrade responsibility. If a new implementation fails to properly inherit the UUPS mixin, or if a developer accidentally deploys an implementation that does not support upgrades, the contract becomes stuck at that version forever—no further upgrades are possible.

Storage layout differs between these patterns because UUPS implementations must reserve a specific storage slot for the implementation pointer. The UUPS standard reserves slot uint256(keccak256('eip1967.proxy.implementation')) - 1 to store the implementation address. Any application state that uses this slot, either by accident or through careless variable declaration, will collide with the upgrade mechanism itself.

Diagnosing Storage Collisions

Storage collisions manifest in several ways. The most obvious is state corruption after an upgrade: the proxy's admin becomes unwritable, or application state variables read stale or incorrect values. The corruption may not be immediate. If the new implementation writes to storage locations that the old implementation never touched, the problem remains dormant until those locations are read.

To diagnose storage layout, developers must understand exactly which slots each contract uses. The Solidity compiler does not expose this information directly in the bytecode. Instead, developers rely on analysis tools and manual inspection of contract source code.

OpenZeppelin provides a storage layout report tool that generates a JSON schema documenting every state variable and its corresponding storage slot. Running this tool on both the proxy and the implementation reveals whether their layouts align. The tool integrates into Hardhat and Foundry, the two most common development frameworks.

Here is how we use the tool with Hardhat. First, install the necessary package:

npm install --save-dev @openzeppelin/hardhat-upgrades
Enter fullscreen mode Exit fullscreen mode

Then, in a Hardhat script, import the storage layout reporter:

const hre = require("hardhat");
const { getStorageLayout } = require("@openzeppelin/hardhat-upgrades/dist/utils");

async function main() {
  const ProxyContract = await hre.ethers.getContractFactory("MyProxy");
  const ImplementationV1 = await hre.ethers.getContractFactory("MyImplementationV1");

  const proxyLayout = await getStorageLayout(hre, ProxyContract);
  const implLayout = await getStorageLayout(hre, ImplementationV1);

  console.log("Proxy Layout:", JSON.stringify(proxyLayout, null, 2));
  console.log("Implementation Layout:", JSON.stringify(implLayout, null, 2));
}

main();
Enter fullscreen mode Exit fullscreen mode

The output shows each variable's name, type, size in bytes, and slot number. Comparing the two outputs reveals collisions immediately. If the proxy uses slots 0 and 1 for admin and implementation pointers, but the implementation declares its first state variable at slot 0, the collision is obvious.

Manual inspection also works for smaller contracts. Open the contract source file and count storage manually. Remember that:

  • Uint256 variables occupy one slot each.
  • Smaller unsigned integers (uint8, uint16, etc.) can be packed together in one slot if they fit.
  • Booleans occupy one slot.
  • Address variables occupy one slot (20 bytes) plus padding to reach 32 bytes.
  • Mappings and dynamic arrays occupy one slot as a storage key and data location.
  • Fixed-size arrays of size N occupy N slots if the array element is uint256-sized, or fewer if smaller types pack.
  • Structs occupy consecutive slots, with packing applied to struct members.

Practical example: a proxy with variables address admin and address implementation occupies two full slots (two addresses do not pack together because each address is 20 bytes and requires 12 bytes of padding). An implementation contract that declares uint256 value as its first state variable will place that value at slot 2, not slot 0, because slots 0 and 1 are already allocated by the proxy's variables. This layout is correct.

However, if the implementation declares address myAddress as its first state variable, the Solidity compiler allocates it to slot 0, colliding with the proxy's admin address. This is incorrect and will cause corruption.

Storage Namespacing Patterns

To avoid collisions systematically, developers adopt storage namespacing patterns. The most reliable approach reserves a range of slots for the proxy and another range for the implementation, documenting the boundary clearly.

The EIP-1967 standard defines storage slot names for common proxy variables. For a transparent proxy, the admin is stored at keccak256("eip1967.proxy.admin") - 1 and the implementation is stored at keccak256("eip1967.proxy.implementation") - 1. These hashes are so large (well into the 2^256 range) that accidental collisions with application state declared at low slot numbers are virtually impossible.

Using EIP-1967 slots looks like this in an implementation contract:

pragma solidity ^0.8.0;

contract MyImplementation {
    // Reserve the low slots for application state
    uint256 public counter;
    address public owner;

    // The proxy stores its admin and implementation at EIP-1967 slots (very high)
    // No collision risk

    function increment() public {
        counter++;
    }
}
Enter fullscreen mode Exit fullscreen mode

The proxy, in turn, stores its admin and implementation at the EIP-1967 slots, not at slot 0:

pragma solidity ^0.8.0;

contract MyProxy {
    // Not stored at slot 0 or 1, but at EIP-1967 slots
    // This is handled by assembly or a library function

    function _setImplementation(address newImpl) internal {
        bytes32 slot = keccak256("eip1967.proxy.implementation") - 1;
        assembly {
            sstore(slot, newImpl)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

By using EIP-1967 slots, the proxy and implementation no longer compete for the low slot numbers. The implementation can declare state variables starting from slot 0 without fear. This pattern has become the standard in production deployments.

Testing Upgrade Paths Before Mainnet

An upgrade path consists of multiple versions. Version 1 is deployed first. Version 2 adds new features and modifies existing state. Version 3 builds on V2, and so on. Testing must verify that:

  1. Storage layout is correct for each version transition.
  2. State is preserved through the upgrade (no corruption or loss).
  3. New functionality works correctly after the upgrade.
  4. Old functionality still works after the upgrade.

Comprehensive upgrade path testing requires simulating the entire sequence of upgrades in a test environment before any version reaches mainnet. This means deploying V1, upgrading to V2, verifying state, upgrading to V3, verifying state again, and so on.

Here is a Hardhat-based test suite that covers an upgrade path with three versions:

const { expect } = require("chai");
const hre = require("hardhat");

describe("Upgrade Path: V1 → V2 → V3", function () {
  let proxy;
  let implV1, implV2, implV3;
  let owner;

  beforeEach(async function () {
    [owner] = await hre.ethers.getSigners();

    // Deploy V1 implementation
    const ImplementationV1 = await hre.ethers.getContractFactory("MyImplementationV1");
    implV1 = await ImplementationV1.deploy();
    await implV1.deployed();

    // Deploy proxy pointing to V1
    const Proxy = await hre.ethers.getContractFactory("MyProxy");
    proxy = await Proxy.deploy(implV1.address, owner.address);
    await proxy.deployed();
  });

  it("should initialize V1 state correctly", async function () {
    const proxyAsV1 = await hre.ethers.getContractAt("MyImplementationV1", proxy.address);
    await proxyAsV1.initialize(100);

    const value = await proxyAsV1.getCounter();
    expect(value).to.equal(100);
  });

  it("should upgrade to V2 without state loss", async function () {
    const proxyAsV1 = await hre.ethers.getContractAt("MyImplementationV1", proxy.address);
    await proxyAsV1.initialize(100);

    // Deploy V2 implementation
    const ImplementationV2 = await hre.ethers.getContractFactory("MyImplementationV2");
    implV2 = await ImplementationV2.deploy();
    await implV2.deployed();

    // Upgrade proxy to V2
    await proxy.upgradeTo(implV2.address);

    // Verify V1 state is preserved
    const proxyAsV2 = await hre.ethers.getContractAt("MyImplementationV2", proxy.address);
    const value = await proxyAsV2.getCounter();
    expect(value).to.equal(100);
  });

  it("should upgrade from V2 to V3 without state loss", async function () {
    const proxyAsV1 = await hre.ethers.getContractAt("MyImplementationV1", proxy.address);
    await proxyAsV1.initialize(100);

    // Upgrade to V2
    const ImplementationV2 = await hre.ethers.getContractFactory("MyImplementationV2");
    implV2 = await ImplementationV2.deploy();
    await implV2.deployed();
    await proxy.upgradeTo(implV2.address);

    // Add V2-specific state
    const proxyAsV2 = await hre.ethers.getContractAt("MyImplementationV2", proxy.address);
    await proxyAsV2.setName("Test");

    // Upgrade to V3
    const ImplementationV3 = await hre.ethers.getContractFactory("MyImplementationV3");
    implV3 = await ImplementationV3.deploy();
    await implV3.deployed();
    await proxy.upgradeTo(implV3.address);

    // Verify both V1 and V2 state are preserved
    const proxyAsV3 = await hre.ethers.getContractAt("MyImplementationV3", proxy.address);
    const counter = await proxyAsV3.getCounter();
    const name = await proxyAsV3.getName();

    expect(counter).to.equal(100);
    expect(name).to.equal("Test");
  });

  it("should prevent upgrades from non-admin accounts", async function () {
    const [, attacker] = await hre.ethers.getSigners();

    const ImplementationV2 = await hre.ethers.getContractFactory("MyImplementationV2");
    implV2 = await ImplementationV2.deploy();
    await implV2.deployed();

    const proxyAsAttacker = proxy.connect(attacker);
    await expect(proxyAsAttacker.upgradeTo(implV2.address)).to.be.revertedWith(
      "Caller is not the admin"
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

This test suite validates the upgrade path from V1 to V2 to V3. Notice that we perform initialization in V1, upgrade to V2, verify state preservation, add new state in V2, upgrade to V3, and verify that both old and new state persist. This approach catches storage collisions and state corruption bugs.

Foundry provides similar testing capabilities using Solidity-based tests. Here is the same suite in Foundry:

pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "../contracts/MyImplementationV1.sol";
import "../contracts/MyImplementationV2.sol";
import "../contracts/MyImplementationV3.sol";
import "../contracts/MyProxy.sol";

contract UpgradePathTest is Test {
    MyProxy proxy;
    MyImplementationV1 implV1;
    MyImplementationV2 implV2;
    MyImplementationV3 implV3;
    address owner = address(0x1234);

    function setUp() public {
        implV1 = new MyImplementationV1();
        proxy = new MyProxy(address(implV1), owner);
    }

    function testV1StateInitialization() public {
        vm.prank(owner);
        MyImplementationV1(address(proxy)).initialize(100);

        uint256 counter = MyImplementationV1(address(proxy)).getCounter();
        assertEq(counter, 100);
    }

    function testUpgradeToV2PreservesState() public {
        vm.prank(owner);
        MyImplementationV1(address(proxy)).initialize(100);

        implV2 = new MyImplementationV2();

        vm.prank(owner);
        proxy.upgradeTo(address(implV2));

        uint256 counter = MyImplementationV2(address(proxy)).getCounter();
        assertEq(counter, 100);
    }

    function testUpgradeV2ToV3PreservesAllState() public {
        vm.prank(owner);
        MyImplementationV1(address(proxy)).initialize(100);

        implV2 = new MyImplementationV2();
        vm.prank(owner);
        proxy.upgradeTo(address(implV2));

        vm.prank(owner);
        MyImplementationV2(address(proxy)).setName("Test");

        implV3 = new MyImplementationV3();
        vm.prank(owner);
        proxy.upgradeTo(address(implV3));

        uint256 counter = MyImplementationV3(address(proxy)).getCounter();
        string memory name = MyImplementationV3(address(proxy)).getName();

        assertEq(counter, 100);
        assertEq(name, "Test");
    }

    function testNonAdminCannotUpgrade() public {
        implV2 = new MyImplementationV2();

        address attacker = address(0x5678);
        vm.prank(attacker);
        vm.expectRevert("Caller is not the admin");
        proxy.upgradeTo(address(implV2));
    }
}
Enter fullscreen mode Exit fullscreen mode

Both test suites validate the upgrade path, but Foundry's tests execute within the EVM directly, providing more accurate gas measurements and state simulation.

Automated Storage Layout Validation in CI/CD

Manual testing catches many issues, but automated storage validation in CI/CD catches collisions before human review. Several tools can be integrated into the CI/CD pipeline to check storage layout automatically.

OpenZeppelin's @openzeppelin/hardhat-upgrades package includes a validation function that compares storage layouts between versions:

const { validateUpgrade } = require("@openzeppelin/hardhat-upgrades");

async function main() {
  const ImplV1Factory = await hre.ethers.getContractFactory("MyImplementationV1");
  const ImplV2Factory = await hre.ethers.getContractFactory("MyImplementationV2");

  try {
    await validateUpgrade(ImplV1Factory, ImplV2Factory, {
      unsafeAllow: ["constructor"], // Allow constructors in upgrade scenarios if needed
    });
    console.log("Storage layout validation passed!");
  } catch (error) {
    console.error("Storage layout validation failed:", error.message);
    process.exit(1);
  }
}

main();
Enter fullscreen mode Exit fullscreen mode

This script compares the storage layouts of V1 and V2, checking for:

  • Added state variables (allowed).
  • Removed state variables (forbidden—causes state shift for remaining variables).
  • Reordered state variables (forbidden—causes existing state to be read as wrong types).
  • Type changes in existing variables (forbidden in most cases).

Integrating this into GitHub Actions or GitLab CI ensures every pull request that modifies a contract is checked:

name: Storage Layout Validation

on:
  pull_request:
    paths:
      - 'contracts/**'
      - 'test/**'

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm install
      - run: npx hardhat run scripts/validateStorageLayout.js
Enter fullscreen mode Exit fullscreen mode

When this workflow runs and storage validation fails, the pull request is blocked from merging until the issue is resolved. This prevents storage collision bugs from reaching production.

Common Storage Mistakes and How to Avoid Them

Several patterns repeatedly cause storage issues in production deployments. Understanding these mistakes helps developers avoid them.

Mistake 1: Double-inheriting Ownable. Many implementations inherit Ownable both directly and indirectly through a base contract. Solidity linearizes inheritance using C3 linearization, but if not carefully managed, a contract can declare the Ownable state variables twice. The second declaration silently overrides the first in the linearization order, causing unexpected storage allocation. To avoid this, inherit Ownable only once, at the top level of the inheritance hierarchy.

Mistake 2: Forgetting proxy storage overhead. Developers often write implementations assuming storage starts at slot 0. If the proxy uses the low slots, the implementation's state ends up at the wrong offsets. The solution is to use EIP-1967 slots for proxy storage, ensuring the proxy and implementation never collide.

Mistake 3: Adding variables to base contracts after deployment. If a base contract that implementations inherit from is modified to add new state variables, all implementations that use that base will have shifted storage. This is difficult to debug because the change is not in the implementation itself. A best practice is to freeze base contracts after the first production deployment. If new functionality is needed, create a new version of the base contract and migrate implementations to inherit from it.

Mistake 4: Changing array or mapping types. A state variable declared as uint256[] occupies a storage slot as a key. If you later change it to mapping(uint256 => uint256), both constructs use the same storage location, but interpret the data differently. This causes silent corruption. Never change the type of existing state variables.

Mistake 5: Upgrading without testing the full path. Developers sometimes test V1 → V2 but assume V2 → V3 will work automatically. Each upgrade introduces new opportunities for state shift. Test every consecutive upgrade before mainnet deployment.

Real-World Example: Upgrading a Token Contract

Consider a token contract that has been deployed for several months. It implements the ERC20 standard and has billions of tokens in circulation. The governance decides to add a fee mechanism: when tokens are transferred, a small percentage goes to a treasury address. A new implementation contract is developed, tested, and deemed ready for upgrade.

The original V1 implementation is:

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TokenV1 is ERC20 {
    constructor() ERC20("MyToken", "MTK") {
        _mint(msg.sender, 1000000 * 10 ** 18);
    }
}
Enter fullscreen mode Exit fullscreen mode

The new V2 implementation adds a treasury and fee:

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract TokenV2 is ERC20, Ownable {
    address public treasury;
    uint256 public feePercentage;

    function initialize(address _treasury, uint256 _feePercentage) public {
        treasury = _treasury;
        feePercentage = _feePercentage;
    }

    function transfer(address to, uint256 amount) public override returns (bool) {
        uint256 fee = (amount * feePercentage) / 100;
        uint256 amountAfterFee = amount - fee;

        _transfer(msg.sender, to, amountAfterFee);
        _transfer(msg.sender, treasury, fee);

        return true;
    }

    function setFeePercentage(uint256 _feePercentage) public onlyOwner {
        feePercentage = _feePercentage;
    }
}
Enter fullscreen mode Exit fullscreen mode

If the TokenV2 implementation is deployed directly (not behind a proxy), it will have a different storage layout than TokenV1. The ERC20 base contract's internal variables (balances, allowances, totalSupply) occupy the low slots. TokenV2 adds treasury and feePercentage, which will be placed at the next available slots. Upgrading from TokenV1 to TokenV2 will preserve ERC20 state but initialize the new treasury and feePercentage to zero (or to whatever values are already in those storage slots from previous contract deployments or noise).

However, if TokenV2 also inherits from Ownable, and Ownable is not carefully placed in the inheritance hierarchy, the Solidity compiler might place Ownable's owner variable at a storage location that overlaps with one of ERC20's internal variables. This causes the new implementation to read and write to the wrong storage slots.

To handle this safely, the upgrade process should:

  1. Deploy TokenV2 to a testnet and verify storage layout.
  2. Run storage layout validation to confirm no collisions with TokenV1.
  3. Initialize a test instance of the proxy with TokenV1 implementation.
  4. Call ERC20 transfer functions to create state.
  5. Upgrade to TokenV2.
  6. Call transfer again and verify balances are correct.
  7. Call setFeePercentage and verify the fee is applied correctly.
  8. Only after all tests pass, deploy TokenV2 to mainnet and execute the upgrade.

Storage Layout Tools and Best Practices

Several tools help developers manage storage layout correctly:

OpenZeppelin Hardhat Upgrades provides storage inspection and validation. It integrates directly into the Hardhat testing framework and reports detailed storage layouts.

Foundry's forge storage command displays the storage layout of a compiled contract. Running forge storage <contract_name> shows every state variable and its storage slot.

Slither, a static analysis tool from Trail of Bits, can detect some storage-related issues during code review, though it is not specialized for upgrade paths.

Manual inspection remains the most reliable method for critical contracts. Taking time to manually count storage slots and document the layout in code comments prevents surprises. Consider adding a storage layout diagram as a comment in the contract:

pragma solidity ^0.8.0;

/**
 * MyImplementationV2
 * Storage Layout:
 * Slot 0: uint256 counter (from V1)
 * Slot 1: address owner (from V1)
 * Slot 2: address treasury (new in V2)
 * Slot 3: uint256 feePercentage (new in V2)
 */

contract MyImplementationV2 {
    uint256 public counter;
    address public owner;
    address public treasury;
    uint256 public feePercentage;
}
Enter fullscreen mode Exit fullscreen mode

This explicit documentation ensures future developers (or your future self) understand the storage layout and respect it during the next upgrade.

Preparing for Mainnet: A Checklist

Before deploying any upgrade to mainnet, verify:

  1. Storage layout has been validated using automated tools.
  2. The upgrade path has been tested from the current production version through all planned upgrade steps.
  3. All tests pass in a local fork of mainnet (using Hardhat's --fork flag or Foundry's --rpc-url flag).
  4. No state variables have been removed from previous versions.
  5. No state variables have been reordered.
  6. No state variable types have changed.
  7. The new implementation has been audited if it introduces significant logic changes.
  8. An upgrade path rollback plan is documented (e.g., how to upgrade to a previous version if the new version is discovered to be broken).
  9. The admin key is secure and not held by a single person (consider using a multisig or time-lock contract).
  10. All team members understand the storage layout and upgrade mechanism.

Following this checklist dramatically reduces the risk of storage-related failures in production.


If you are building Web3 systems that require production-grade documentation or developing a full-stack Next.js application, I am available for both Web3 technical writing and end-to-end web development. Visit my Fiverr profile at https://fiverr.com/meric_cintosun to discuss your project.

Top comments (0)