The Upgrade That Breaks Everything Quietly
You deploy a lending protocol. Six months pass, the team ships an upgrade, the proxy points to a new logic contract, and suddenly the accounting is wrong. Not obviously wrong. Subtly, catastrophically wrong. Balances read correctly until someone tries to withdraw, and then the math doesn't add up.
No hack. No exploit in the traditional sense.
Just two contracts that both thought they owned the same storage slot.
This is the storage collision problem in proxy patterns, and it is, in this writer's view, the most counterintuitive failure mode in all of smart contract engineering. Not the flashiest. The most treacherous.
Why Proxies Exist in the First Place
Ethereum contracts are immutable once deployed. The address is permanent, the bytecode is frozen. For a protocol that needs to fix bugs or add features, that constraint is serious.
The proxy pattern is the workaround. You deploy two contracts: a lightweight proxy that holds all the state and receives all user calls, and a separate logic contract that holds all the executable code. When a user calls the proxy, it uses `delegatecall` to run the logic contract's code in the proxy's own storage context. The logic contract's address can be swapped out for a new version whenever the team ships an upgrade.
Clean in theory. The problem lives in what `delegatecall` actually does.
What delegatecall Actually Does to Storage
When contract A calls contract B via `delegatecall`, B's code runs but all storage reads and writes happen against A's storage layout. Not B's. A's.
Solidity assigns state variables to storage slots sequentially, starting at slot 0. A `uint256 balance` at the top of a contract lives in slot 0. The next variable goes in slot 1. And so on.
Now consider the classic transparent proxy setup. The proxy contract needs to store the address of the current logic contract somewhere. The obvious place is a state variable, which lands in slot 0. The logic contract also has a state variable, probably something like `address owner`, which also lands in slot 0.
When the logic contract runs via `delegatecall` and reads `owner` from slot 0, it is reading whatever value the proxy stored there as its `implementation` address. When the proxy updates its `implementation` pointer, it is writing to the same slot the logic contract treats as `owner`.
Those two variables are physically the same 32-byte location in the EVM. Neither contract knows about the other's interpretation. The EVM doesn't care. It just reads and writes slot 0.
A Worked Scenario: Two Contracts, One Slot, One Disaster
Call the logic contract `VaultV1`. Its layout looks like this:
``` slot 0: address owner slot 1: uint256 totalDeposits slot 2: mapping(address => uint256) balances ```
The proxy stores its `implementation` address in slot 0 because the developer wrote it as a plain state variable.
A user deposits 10 ETH. `totalDeposits` in slot 1 increments correctly. So far, fine.
The team upgrades to `VaultV2`. The proxy writes the new logic contract's address, say `0xABCD...1234`, into slot 0. That 20-byte address, zero-padded to 32 bytes, now occupies slot 0.
The next time any function in the logic contract checks `owner`, it reads `0x000000000000000000000000ABCD...1234`. That looks like a valid address. Access control passes or fails in unexpected ways depending on the check. Worse, if `VaultV2` added a new variable at the top of its layout, bumping `totalDeposits` from slot 1 to slot 2, then `totalDeposits` now reads from what was previously the `balances` mapping root.
The accounting is silently corrupted. No event was emitted for it. No revert happened. The state just means something different now.
The Three Patterns and Their Tradeoffs
The Ethereum development community has produced three main answers to this problem, each carrying real tradeoffs.
Unstructured Storage (EIP-1967). Instead of using slot 0 for the implementation pointer, you hash a unique string and use that as the slot location. The EIP-1967 standard defines the implementation slot as `keccak256("eip1967.proxy.implementation") - 1`, which resolves to a specific 32-byte value near the top of the 2^256 address space. The probability of a logic contract's sequential variables ever reaching that slot is, for all practical purposes, zero. OpenZeppelin's `TransparentUpgradeableProxy` and `UUPSUpgradeable` both follow this standard. It solves the collision for the implementation pointer itself, but does not automatically protect you from collisions between V1 and V2 of your own logic contract.
Transparent Proxy Pattern. The proxy intercepts calls from the admin address and handles upgrade logic itself, routing all other callers straight to `delegatecall`. This separates admin functions from user functions cleanly. The cost is a small gas overhead on every call because the proxy has to check who's calling.
UUPS (Universal Upgradeable Proxy Standard, EIP-1822). Here the upgrade logic lives inside the logic contract, not the proxy. The proxy is extremely thin. This saves gas and gives the logic contract author more control, but it introduces a new risk: if you deploy a new logic contract that accidentally omits the upgrade function, you have permanently bricked upgradeability. The Audius protocol encountered this in practice when a governance exploit temporarily pointed their proxy at a contract that lacked the right upgrade path.
None of these patterns eliminate the storage layout compatibility problem between versions of your own logic contract. That is a different discipline entirely.
The Deeper Problem: Layout Compatibility Across Versions
This is where the real danger lives, and where most documentation goes thin.
When you upgrade from `VaultV1` to `VaultV2`, you must not change the order or type of any existing state variables. You can add new variables at the end of the layout. You cannot insert a variable in the middle, remove one, or change a `uint256` to a `uint128`. Any of those moves shifts subsequent slots and corrupts every value stored in them.
The reason is simple: the proxy's storage is a flat array of 32-byte slots, with no memory of what types were stored where. Think of it as a warehouse where boxes are tracked only by shelf number, never by label. `VaultV2` arrives with its own shelf map, and the EVM applies that map to goods stacked under `VaultV1`'s rules.
Consider a concrete example. Maya and Priya both deposited into a vault before an upgrade. Maya's balance lives in slot 2 under `VaultV1`'s layout. The team ships `VaultV2` and inserts a new `address feeRecipient` variable between `owner` and `totalDeposits`, pushing everything down by one slot. Now Maya's balance is being read from slot 3, which holds Priya's balance. Both users query the same storage slot. One of them sees a number that was never their deposit.
This is why serious protocol teams use storage layout checkers like OpenZeppelin's `hardhat-upgrades` plugin, which will refuse to compile an upgrade that breaks layout compatibility, comparing the storage layout of the new implementation against the old one and throwing an error if anything has shifted. Treating that check as optional is a discipline failure, it is precisely how protocols end up with silent accounting bugs that only surface when a user tries to withdraw.
What Auditors Actually Look For
A security review of a proxy-based protocol will check a specific list. Does the implementation slot follow EIP-1967? Is the initializer function protected against being called twice (the `initializer` modifier in OpenZeppelin's pattern), given that constructors do not run under `delegatecall` and a re-entrant initializer can reset ownership? Does the `selfdestruct` opcode appear anywhere in the logic contract, because calling it via `delegatecall` destroys the proxy, not the logic contract? And has every upgrade been tested against the prior version's storage layout, not merely compiled?
Ask yourself: how many protocols you have used could answer yes to all four without checking?
Found a project that ticks every box? You are looking at a team that has actually read the failure reports.
The proxy pattern is genuinely elegant engineering, and upgradeable contracts have saved real user funds when critical bugs were patched before exploits could drain them. But the mechanism that makes upgrades possible, `delegatecall` executing foreign code against local storage, is the same mechanism that makes collisions possible. Those two facts do not cancel each other out. They coexist, which means every proxy deployment is a standing bet that the team's discipline around storage layout will hold across every future upgrade, indefinitely.
The bet is not unreasonable. It just needs to be made with both eyes open.