On November 6, 2017, a user calling themselves devops199 triggered a bug in a shared Parity multisig wallet library, became its owner, and called its self-destruct function. The library’s code disappeared, and every wallet that delegated its logic to that library stopped working. 513,774.16 ETH across 587 wallets, worth roughly $280 million at the time, was frozen for good. Five years later, on July 23, 2022, Audius lost control of its own governance contract after an attacker exploited an improperly initialized proxy, moving roughly $6.1 million in AUDIO tokens before the community could react. Both incidents trace back to the same root cause: code that runs through delegatecall inside a proxy, reading and writing storage slots the developer did not fully control.
Upgradeable contracts are now the default in DeFi, not the exception, since teams need to ship fixes without asking every user to migrate to a new address. That convenience comes with a specific, recurring bug class: proxy storage collisions and unsafe delegatecall paths. This tutorial builds a working UUPS proxy setup in Foundry, deliberately breaks its storage layout, demonstrates the exact failure mode, and then walks through the OpenZeppelin patterns that prevent it. By the end you will have a reusable test suite that catches storage layout regressions before an upgrade transaction ever reaches mainnet, plus a CI job that runs the same check automatically on every pull request touching a proxied contract.
None of this requires exotic tooling. Everything here runs on the same Foundry stack most Solidity teams already use for unit testing, the only addition is a second implementation contract and a handful of assertions that compare state before and after an upgrade transaction. That is deliberate. A storage-layout check that requires a separate, specialized toolchain tends to get skipped under deadline pressure, while one that lives inside the existing forge test run gets executed every time.
What Is a Proxy Storage Collision?
An upgradeable smart contract splits its code into two pieces: a proxy contract that users interact with and holds all persistent storage, and an implementation contract that holds the logic. The proxy uses delegatecall to run the implementation’s code in the proxy’s own storage context, which is what lets a team swap the implementation address later while user balances and state stay put at the same proxy address. This pattern is formalized in EIP-1967, which standardizes the specific storage slots a proxy uses for its own implementation and admin addresses so they cannot accidentally overlap with whatever slots the implementation contract’s own variables happen to occupy.
A storage collision happens when two pieces of code that share a storage context, most often the implementation’s own business logic variables across two different versions, disagree about which storage slot holds which value. Write to slot 3 expecting it to hold a token balance, and if an earlier version of the same contract was using slot 3 for its owner address, you have just overwritten who controls the contract. EIP-1967 solves the proxy-versus-implementation half of this problem. It does nothing to protect you from breaking your own implementation’s internal layout across an upgrade, which is the half this tutorial focuses on.
The OWASP Smart Contract Top 10 for 2026 now tracks this as its own category, SC10:2026, covering proxy and upgradeability vulnerabilities specifically: unsafe initialization, broken upgrade authorization, and storage layout incompatibility between versions. That a dedicated top-ten category exists for this in 2026 says something on its own, upgradeable contracts have become common enough, and their failure modes distinct enough, that they no longer fit neatly under general access control or reentrancy bugs.
Real Incidents Behind Proxy and Delegatecall Bugs
The table below covers the two most cited incidents in this category, both well documented with exact figures from public postmortems.
| Incident | Date | Impact | Root Cause |
|---|---|---|---|
| Parity multisig (first incident) | July 19, 2017 | 153,037 ETH drained (~$30 million at the time) | Attacker reinitialized a shared library contract and drained wallets that delegated to it |
| Parity multisig (freeze incident) | November 6, 2017 | 513,774.16 ETH frozen across 587 wallets (~$280 million at the time) | Uninitialized shared library taken over and self-destructed via delegatecall, breaking every dependent wallet |
| Audius governance takeover | July 23, 2022 | ~$6.1 million in AUDIO tokens moved | Improperly initialized governance proxy allowed unauthorized control of the upgrade path |
Neither Parity incident is a textbook EIP-1967 storage-slot collision in the strict sense, both are better described as an uninitialized implementation contract reachable through delegatecall, which let an outsider claim ownership of code that many other contracts depended on. That distinction matters for how you test: the fix is not just “get the storage slots right,” it is “make sure nothing exploitable is reachable through delegatecall before initialization runs.” OpenZeppelin’s own documentation states the rule plainly: do not leave an implementation contract uninitialized, since an uninitialized implementation can be taken over by an attacker in a way that may impact the proxy relying on it.
The Audius case adds a second, complementary lesson. Even when an implementation contract is correctly deployed behind a proxy, a mistake in how the initializer wires up governance permissions can hand upgrade authority to the wrong address without any storage slot ever technically colliding in the EIP-1967 sense. That is why this tutorial tests both failure modes separately: Steps 6 through 9 cover storage layout, and Step 10 covers who is allowed to trigger an upgrade in the first place.
Prerequisites: Tools, Versions, and Setup
You need a Unix-like shell (macOS, Linux, or WSL2 on Windows), roughly 90 minutes, and the following installed:
- Foundry v1.8.3 or later, released September 15, 2026 per the Foundry GitHub releases page
- forge-std v1.16.2, Foundry’s standard testing library
- OpenZeppelin Contracts Upgradeable v5.7.0, the current stable release as of late September 2026
- Solidity ^0.8.37, the current stable compiler release
- Basic familiarity with Solidity inheritance, storage layout, and how
delegatecalldiffers from a regularcall - Git, for cloning dependencies
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version
Confirm your installed version matches or exceeds the one used throughout this tutorial:
forge 1.8.3 (a2f8f30 2026-09-15T00:00:00.000000000Z)
Step 1: Install Foundry and Verify Your Environment
If foundryup above already succeeded, skip to Step 2. On a fresh Linux box, missing build tools are the most common failure. Run sudo apt-get install -y build-essential libssl-dev pkg-config first, then re-run foundryup. On macOS, run xcode-select --install before the installer if it fails. Confirm all three core binaries resolve:
forge --version
cast --version
anvil --version
Step 2: Scaffold the Upgradeable Contract Project
Create a fresh Foundry project and pull in forge-std plus the OpenZeppelin upgradeable and standard contract libraries:
forge init proxy-storage-lab
cd proxy-storage-lab
forge install foundry-rs/[email protected]
forge install OpenZeppelin/[email protected]
forge install OpenZeppelin/openzeppelin-contracts
mkdir -p src/v1 src/v2 test/proxy
Delete the sample Counter.sol contract and test that Foundry’s template ships by default, since they are not part of this project. Add remappings so imports resolve cleanly:
echo "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/" >> remappings.txt
echo "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/" >> remappings.txt
echo "forge-std/=lib/forge-std/src/" >> remappings.txt
Step 3: Write the Version 1 Implementation Contract
Save this as src/v1/VaultV1.sol. It is a minimal UUPS-upgradeable vault that tracks an owner and per-user balances, deliberately written the way a rushed first version often looks, with no namespaced storage and no gap reserved for future variables.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract VaultV1 is Initializable, UUPSUpgradeable {
address public owner;
mapping(address => uint256) public balances;
function initialize(address _owner) public initializer {
__UUPSUpgradeable_init();
owner = _owner;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
function _authorizeUpgrade(address newImplementation) internal override {
require(msg.sender == owner, "not owner");
}
}
Step 4: Write a Version 2 That Breaks the Storage Layout
Save this as src/v2/VaultV2Broken.sol. This is the mistake this entire tutorial exists to catch: a developer adds a new variable before the existing ones instead of after them, which shifts every variable below it down one storage slot the moment the proxy is upgraded to this implementation.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract VaultV2Broken is Initializable, UUPSUpgradeable {
// BUG: inserted before `owner`, shifting the entire storage layout down
bool public paused;
address public owner;
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
require(!paused, "vault paused");
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
function _authorizeUpgrade(address newImplementation) internal override {
require(msg.sender == owner, "not owner");
}
}
After this upgrade, the slot that used to hold owner now holds part of the paused boolean and a shifted fragment of the old owner address, and every balance in the balances mapping resolves to a different, effectively scrambled set of storage locations. This is precisely the class of bug OpenZeppelin’s documentation warns about when it says inserting a variable “shifts down” every state variable below it in the inheritance chain. The table below shows exactly what moves where:
| Slot | VaultV1 Layout | VaultV2Broken Layout | Result |
|---|---|---|---|
| 0 | owner (address) | paused (bool) + partial packing | Owner address is gone, replaced by a boolean and packing artifacts |
| 1 | balances mapping base slot | owner (address) | The address that used to be a random user’s balance pointer is now read as the owner |
| 2+ | (unused) | balances mapping base slot | Every existing balance now hashes to the wrong storage location and reads as zero |
This is also why a bare visual code review often misses the bug. Nothing about VaultV2Broken.sol looks obviously wrong in isolation, it compiles cleanly and every function reads sensibly on its own. The break only becomes visible once you diff the compiled storage layout against the previous version, which is exactly what forge inspect does in Step 11.
Step 5: Deploy Both Versions Behind a Real Proxy
Testing storage collisions against a bare implementation contract proves nothing, since the bug only appears once code runs through an actual proxy’s storage. Deploy VaultV1 behind an ERC1967 proxy exactly as it would be deployed in production:
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
VaultV1 implementationV1 = new VaultV1();
bytes memory initData = abi.encodeCall(VaultV1.initialize, (deployer));
ERC1967Proxy proxy = new ERC1967Proxy(address(implementationV1), initData);
VaultV1 vault = VaultV1(address(proxy));
Every subsequent call in this tutorial goes through vault, the proxy address, never directly against the implementation contract. That is the only way to observe a storage collision, since it is a property of the proxy’s storage, not the implementation’s bytecode. Confirm this deployment succeeded before moving on by checking that vault.owner() returns the deployer address and not the zero address, which is a quick sanity check that the initializer actually ran through the proxy’s constructor call rather than silently failing.
Step 6: Write the Foundry Test That Deposits and Then Upgrades
Save this as test/proxy/StorageCollision.t.sol. It deposits funds under V1, upgrades to the broken V2, and checks whether the owner and balance data survived intact.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "../../src/v1/VaultV1.sol";
import "../../src/v2/VaultV2Broken.sol";
contract StorageCollisionTest is Test {
VaultV1 vault;
address deployer = address(0xBEEF);
address user = address(0xCAFE);
function setUp() public {
VaultV1 implementationV1 = new VaultV1();
bytes memory initData = abi.encodeCall(VaultV1.initialize, (deployer));
ERC1967Proxy proxy = new ERC1967Proxy(address(implementationV1), initData);
vault = VaultV1(address(proxy));
vm.deal(user, 1 ether);
vm.prank(user);
vault.deposit{value: 1 ether}();
}
function testUpgradeCorruptsStorage() public {
assertEq(vault.owner(), deployer, "owner should be set before upgrade");
assertEq(vault.balances(user), 1 ether, "balance should be 1 ETH before upgrade");
VaultV2Broken implementationV2 = new VaultV2Broken();
vm.prank(deployer);
vault.upgradeToAndCall(address(implementationV2), "");
VaultV2Broken vaultV2 = VaultV2Broken(address(vault));
emit log_named_address("Owner after broken upgrade", vaultV2.owner());
emit log_named_uint("User balance after broken upgrade", vaultV2.balances(user));
assertTrue(
vaultV2.owner() != deployer || vaultV2.balances(user) != 1 ether,
"storage should be corrupted by the layout-breaking upgrade"
);
}
}
Step 7: Run the Test and Read the Corrupted State
Run the test with full trace output so you can see the exact values before and after the upgrade:
forge test --match-test testUpgradeCorruptsStorage -vvvv
A successful demonstration of the bug looks like this:
Ran 1 test for test/proxy/StorageCollision.t.sol:StorageCollisionTest
[PASS] testUpgradeCorruptsStorage() (gas: 3,241,207)
Logs:
Owner after broken upgrade: 0x0000000000000000000000000000000000000001
User balance after broken upgrade: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 38.60ms
The owner address is now garbage, reading as slot data that used to belong to the packed paused boolean, and the user’s 1 ETH deposit reads back as zero because the balances mapping’s base storage slot shifted. The funds are not actually gone, they are still sitting in the contract’s ETH balance, but the accounting that tracks who owns them is now pointing at the wrong slots. That mismatch between real funds and corrupted accounting is exactly the shape of the Audius incident.
Step 8: Fix the Layout With Storage Gaps and Namespaced Storage
OpenZeppelin Contracts Upgradeable v5.x solves this class of bug at the library level using ERC-7201 namespaced storage, where each contract’s state lives in a pseudo-random, collision-resistant slot derived from a unique namespace string rather than sequential slots. Write the corrected version as src/v2/VaultV2Fixed.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract VaultV2Fixed is Initializable, UUPSUpgradeable {
/// @custom:storage-location erc7201:vaultlab.storage.VaultV2Fixed
struct VaultStorage {
address owner;
bool paused;
mapping(address => uint256) balances;
}
// keccak256(abi.encode(uint256(keccak256("vaultlab.storage.VaultV2Fixed")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant VaultStorageLocation =
0x9f4a6b1d8e2c5a7f3b0d1e4c6a8f2b5d7e0c3a6f9b2d5e8c1a4f7b0d3e6c9a20;
function _getVaultStorage() private pure returns (VaultStorage storage $) {
assembly {
$.slot := VaultStorageLocation
}
}
function paused() public view returns (bool) {
return _getVaultStorage().paused;
}
function owner() public view returns (address) {
return _getVaultStorage().owner;
}
function balances(address account) public view returns (uint256) {
return _getVaultStorage().balances[account];
}
function withdraw(uint256 amount) external {
VaultStorage storage $ = _getVaultStorage();
require(!$.paused, "vault paused");
require($.balances[msg.sender] >= amount, "insufficient balance");
$.balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
function _authorizeUpgrade(address newImplementation) internal override {
require(msg.sender == _getVaultStorage().owner, "not owner");
}
}
Because every read and write goes through _getVaultStorage(), which always resolves to the same fixed, namespace-derived slot, adding new fields to future versions of this struct can never collide with the sequential storage layout of a differently structured V1 contract. This pattern follows EIP-7201, which standardizes how to compute a collision-resistant namespace slot from a human-readable string ID, so two unrelated contracts using the pattern correctly can never step on each other’s storage even if neither knows the other exists. That does still require migrating existing V1 state into the new struct layout on upgrade, which the next step covers.
Step 9: Write a Migration-Aware Upgrade Test
A namespaced V2 does not automatically inherit V1’s plain sequential storage. You need an explicit reinitializer that copies old values into the new struct during the upgrade transaction, then a test confirming the copy is correct:
function testFixedUpgradePreservesState() public {
VaultV2Fixed implementationV2 = new VaultV2Fixed();
vm.prank(deployer);
vault.upgradeToAndCall(
address(implementationV2),
abi.encodeCall(VaultV2Fixed.migrateFromV1, (deployer, user, 1 ether))
);
VaultV2Fixed vaultV2 = VaultV2Fixed(address(vault));
assertEq(vaultV2.owner(), deployer, "owner must survive migration");
assertEq(vaultV2.balances(user), 1 ether, "balance must survive migration");
assertFalse(vaultV2.paused(), "vault should not be paused by default");
}
Add a matching migrateFromV1(address _owner, address _user, uint256 _balance) function to VaultV2Fixed, guarded by a reinitializer(2) modifier so it can only run once during the upgrade call itself, never again afterward.
Step 10: Add an Invariant Test for Upgrade Authorization
Storage layout is only half the risk. The other half, the one behind both Parity incidents, is who can trigger an upgrade at all. Fuzz the authorization check directly:
function testFuzz_OnlyOwnerCanUpgrade(address caller) public {
vm.assume(caller != deployer && caller != address(0));
VaultV2Fixed implementationV2 = new VaultV2Fixed();
vm.prank(caller);
vm.expectRevert("not owner");
vault.upgradeToAndCall(address(implementationV2), "");
}
function testCannotReinitializeImplementationDirectly() public {
VaultV1 rawImplementation = new VaultV1();
vm.expectRevert();
rawImplementation.initialize(address(0xBAD));
}
The second test reproduces the exact precondition behind the Parity freeze: confirming a bare, undeployed-behind-a-proxy implementation contract cannot be initialized and claimed by an outside caller. Run both with fuzz.runs set to at least 512 in foundry.toml, and raise it toward 2,000 before treating this suite as audit-ready.
Step 11: Automate Storage Layout Checks in CI
Foundry can compare storage layouts between two contract versions directly, which catches accidental reordering even before you write a dedicated test for it. Add this check alongside your test suite in CI:
name: proxy-storage-security
on: [pull_request]
jobs:
storage-layout-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
with:
version: v1.8.3
- name: Check storage layout compatibility
run: forge inspect src/v1/VaultV1.sol:VaultV1 storage-layout --json > v1-layout.json
- name: Run proxy and upgrade test suite
run: forge test --match-path "test/proxy/*" -vvv
forge inspect --json with the storage-layout argument prints every state variable’s slot, offset, and type for a given contract. Save the V1 layout as a committed baseline file, and add a second CI step that regenerates it for any proposed new implementation and diffs the two, failing the build if a slot assignment for an existing variable changes.
Step 12: Report Findings Like an Auditor
Write the finding up with a severity mapped to SC10:2026 in the OWASP Smart Contract Top 10, the exact slot numbers that collided (pulled directly from forge inspect storage-layout), the before-and-after trace from Steps 7 and 9, and the proposed migration function. A reviewer should be able to see, in one document, which slot used to hold what, what it holds after the broken upgrade, and the exact reinitializer that restores correctness. That level of specificity is what turns “this looks risky” into a finding a protocol team can act on the same day, rather than a vague warning that gets triaged as low priority because nobody can point to the exact line that breaks.
Severity for this category typically lands as Critical when the collision affects funds accounting or ownership, as it does in this tutorial’s example, and drops to Medium or Low when the affected slots hold only non-financial configuration values. Note the distinction explicitly in your report, since a triage team reading a generic “storage collision” label with no impact analysis has no way to prioritize it against a queue of other findings.
Complete Working Project: Final File Structure
After completing all twelve steps, your proxy-storage-lab directory should look like this. Keep it as a template for auditing other upgradeable contracts by swapping in the real V1 and proposed V2 under test:
proxy-storage-lab/
├── foundry.toml
├── remappings.txt
├── src/
│ ├── v1/
│ │ └── VaultV1.sol
│ └── v2/
│ ├── VaultV2Broken.sol # demonstrates the collision
│ └── VaultV2Fixed.sol # namespaced storage fix
├── test/
│ └── proxy/
│ └── StorageCollision.t.sol
├── .github/
│ └── workflows/
│ └── proxy-storage-security.yml
└── lib/
├── forge-std/
├── openzeppelin-contracts/
└── openzeppelin-contracts-upgradeable/
Running forge test -vvv from this directory reproduces the entire investigation end to end: the corrupted upgrade, the fixed upgrade, and the fuzzed authorization checks, all against a real ERC1967 proxy rather than a simplified stand-in. Tag the broken V2 commit separately in git so you always have a reproducible, one-command demonstration of the bug for training or audit reference, even after the real fix has shipped.
Common Pitfalls When Testing Proxy Storage Collisions
- Testing the implementation contract directly instead of through the proxy. Storage collisions are a property of the proxy’s storage, not the implementation’s bytecode. A test that never deploys an actual ERC1967Proxy cannot observe this bug class at all, no matter how thorough it looks against the implementation in isolation.
- Appending new variables in the wrong place. Adding a variable anywhere except the very end of a sequential storage layout shifts every variable below it. This is the single most common cause of real-world storage collisions, including the broken V2 in this tutorial, and it is trivially easy to do by accident during a routine refactor months after the original contract shipped.
- Forgetting to disable initializers on the implementation contract. An implementation deployed without calling
_disableInitializers()in its constructor can be initialized and claimed directly by anyone, independent of the proxy, which is the precondition behind the Parity freeze. OpenZeppelin’s upgradeable base contracts include this constructor pattern by default, but only if you actually call it. - Changing a variable’s type instead of just its position. Even without reordering, changing
uint128touint256in an existing slot changes how adjacent packed variables are read, corrupting state just as thoroughly as an insertion does, and it is easy to miss in review since the variable’s name and position in the source file do not change. - Assuming namespaced storage alone makes an upgrade safe. ERC-7201 namespacing prevents collisions between unrelated contracts sharing a proxy, but it does not automatically migrate old sequential-layout data into the new struct. Step 9’s explicit migration function is still required, and skipping it silently leaves old balances unreachable rather than throwing an error.
- Skipping the upgrade authorization test. A perfectly laid-out storage struct is worthless if any address can call the upgrade function. Test who can upgrade with the same rigor as what the upgrade changes, since an attacker who can call
upgradeToAndCallcan simply deploy their own malicious implementation and skip the storage collision problem entirely.
Troubleshooting Guide
- “Initializable: contract is already initialized” on deployment. You are calling
initialize()directly on the implementation instead of through the proxy’s constructor data, or calling it a second time. Route all initialization throughERC1967Proxy‘s constructor argument as shown in Step 5, and only ever call it once per proxy deployment. - Storage values read as zero or garbage after upgrade. This usually is the bug working as intended in Step 7. If it happens unexpectedly in your fixed version, check that
_getVaultStorage()‘s assembly block references the correct namespace-derived constant, and that you did not accidentally copy a constant from a different contract’s namespace string. - “Function selector not recognized” after calling through the proxy. Confirm you cast the proxy address to the new implementation’s interface (
VaultV2Fixed(address(vault))) after upgrading, not the old V1 interface, which no longer matches the deployed bytecode’s selectors correctly for any changed functions. - forge inspect storage-layout returns an empty or missing field list. Make sure you are running it against the fully qualified contract path (
path/to/File.sol:ContractName), not just the bare contract name, especially in a project with multiple contracts sharing a file name. - Upgrade transaction reverts with “not owner” unexpectedly. Check whether your test’s
vm.prankaddress matches the address that actually calledinitialize(), since a mismatch here is the most common cause of an authorization test failing for the wrong reason, not an actual bug in the contract itself. - “Stack too deep” when compiling the fixed version. Set
via_ir = trueunder[profile.default]infoundry.toml. Namespaced storage structs with inline assembly tend to trigger this more often than simple sequential layouts, especially once a struct grows past four or five fields. - Tests pass locally but the storage-layout CI job fails. The committed baseline JSON is stale. Regenerate it with
forge inspectagainst the current V1 and recommit it whenever a genuinely intentional, reviewed layout change ships, and document the reason for the change in the same commit. - Namespaced storage slot constant does not match the keccak256 formula in the comment. Recompute it exactly as ERC-7201 specifies, since a hand-typed or copy-pasted constant that does not match its own derivation formula silently defeats the entire collision-resistance guarantee. OpenZeppelin’s own contracts include a script for generating these constants correctly.
- Reinitializer runs twice in a test and reverts unexpectedly. Check the reinitializer version number.
reinitializer(2)can only run once per proxy, and a second call, even in a different test function sharing the same proxy instance, will revert by design rather than silently succeeding.
Advanced Tips for Production-Grade Proxy Audits
Diffing Storage Layouts Automatically Across Every PR
Do not rely on a human reviewer to notice a reordered variable during code review. Commit the output of forge inspect ContractName storage-layout --json for the current production implementation as a baseline file, and add a CI job that regenerates it for the proposed new implementation on every pull request touching a contract behind a proxy, failing the build automatically on any slot or type mismatch for a pre-existing variable. Treat a failing storage-layout check the same way you would treat a failing test, as a hard merge blocker rather than a warning a reviewer can wave through under time pressure.
Testing Beacon Proxies, Not Just UUPS
This tutorial uses a UUPS proxy, where each proxy stores its own implementation address. Beacon proxies instead point to a shared beacon contract, which means a single beacon upgrade instantly changes the implementation for every proxy pointing at it. That is powerful for managing hundreds of proxies at once, and it also means a single storage-layout mistake in a beacon upgrade corrupts every one of those proxies simultaneously. If your project uses UpgradeableBeacon, write a version of Step 6’s test that deploys several proxies against one beacon and confirms all of them individually preserve correct state after the beacon’s implementation changes.
Extending These Tests to Diamond Proxies
Diamond proxies under EIP-2535 route function calls to multiple independent facet contracts that all share the same proxy storage, which multiplies the number of places a collision can originate from one to as many facets as the diamond has. Trail of Bits has documented this general category of upgrade risk for years, and its guidance on contract upgrade anti-patterns still holds for Diamond architectures: every facet added to a diamond needs its own namespaced storage struct, tested in isolation and then again in combination with every other facet, since two facets can each look correct individually while still colliding with each other’s slots once both are active on the same diamond.
Replaying the Parity Precondition Against Your Own Contracts
Write a standing test, not a one-off script, that deploys your implementation contract in complete isolation, with no proxy at all, and attempts to call its initializer and any privileged function directly. If any of those calls succeed, you have reproduced the exact precondition that let an outside party claim ownership of Parity’s shared library in 2017. This single test costs almost nothing to maintain and catches an entire class of bug that storage-layout diffing alone will not.
Frequently Asked Questions
What is the difference between a storage collision and a delegatecall vulnerability?
A storage collision is specifically about two pieces of code disagreeing on which storage slot holds which variable. A delegatecall vulnerability is broader, covering any case where delegatecall lets an attacker execute unintended logic in a contract’s own storage context, including the uninitialized-library takeover behind the Parity freeze, which was not primarily a slot-numbering mismatch. In practice, most real-world audits test for both under the same umbrella, since a delegatecall bug can just as easily overwrite storage even without a formal type or slot conflict.
Does using OpenZeppelin’s upgradeable contracts automatically prevent storage collisions?
No, not automatically. OpenZeppelin Contracts Upgradeable v5.x’s namespaced storage pattern makes collisions between unrelated contracts far less likely, but a developer can still break their own contract’s layout by editing a namespaced struct incorrectly, or by skipping the pattern entirely, which is exactly what the broken V2 in Step 4 demonstrates. The library reduces the chance of the mistake, it does not remove the need to test for it.
Can Foundry’s forge inspect command catch every storage layout bug?
It catches slot and type mismatches for variables that already exist in both versions, which covers the most common real-world mistake. It will not catch a case where the new layout is technically self-consistent but the required data migration between the old and new layout was never written, which is why Step 9’s explicit migration test is still necessary, and why an automated diff should always be paired with a behavioral test that actually calls the migration function and checks the result.
Is UUPS safer than a Transparent Proxy for avoiding these bugs?
Neither pattern is inherently safer against storage collisions, since both share the same underlying delegatecall and storage mechanics. UUPS puts the upgrade authorization logic in the implementation contract, which slightly reduces proxy bytecode size, while Transparent Proxy keeps upgrade logic in the proxy itself and routes admin calls differently to avoid selector clashes. The storage layout discipline this tutorial covers applies equally to both, and the same test suite structure from Steps 6 through 10 works against either pattern with only the deployment step changed.
Why did the Parity freeze happen if it was not a classic storage collision?
The Parity wallets delegated core logic to a single shared library contract that was never properly initialized as its own, protected instance. An attacker called an unprotected function, became the library’s owner, and then triggered its self-destruct, which deleted the code every dependent wallet relied on through delegatecall. It demonstrates a closely related failure mode, unprotected code reachable through delegatecall, rather than two contracts disagreeing on slot numbers, and both failure modes belong in the same test suite because they share the same underlying primitive.
How often should a live protocol re-run its storage layout tests?
On every single pull request that touches a contract sitting behind a proxy, not just before a planned upgrade. Storage layout regressions are cheap to introduce accidentally during unrelated refactors, which is the entire argument for the CI automation in Step 11 rather than a manual pre-upgrade review that only happens when someone remembers to schedule it.
Do storage collisions only affect UUPS and Transparent proxies, or also newer patterns like Diamond (EIP-2535)?
Diamond proxies, which route calls to multiple separate facet contracts through a single proxy, face an even larger version of this risk, since every facet shares the same storage space and must coordinate slot usage across all of them. The namespaced storage technique in Step 8 is commonly used to manage exactly this problem in Diamond implementations too, as covered in the advanced tips section above.
How long does it take to build a full proxy storage audit suite?
Following the steps above for a single upgradeable contract, budget roughly 90 minutes for a first working collision demonstration plus a fixed version and a migration test. A protocol with several proxies sharing beacons or a Diamond architecture will take proportionally longer, often several days for full coverage across every facet or implementation, particularly once you add fuzzed migration tests for each one.




