Upgrading Smart Contracts Safely with ERC-7201
26. September 2025
If you’ve ever upgraded a smart contract and watched perfectly good state turn to junk, you’ve seen storage collisions. Old state gets written over and variables become corrupted, rendering the contract virtually useless. There are examples of how simple storage collisions can cause millions of dollars in damages, which happened to Audius, because of how silently collisions occur.
First adopted in 2023 by OpenZeppelin's v5 contracts, ERC-7201 fixes these issues with storage namespaces - a way of physically separating logical storage modules into their pieces of memory, so upgrades don't corrupt unrelated state.
The collision problem (refresher)
Solidity puts state variables sequentially in slots. Proxies delegatecall
into new logic contracts, but they keep the old storage. If you reorder and/or insert variables incorrectly, you could shift slots and silently overwrite existing values.
// v1 - original layout
contract VaultV1 {
/* +------+----------+--------+
* | slot | variable | value |
* +------+----------+--------+
* | 0 | a | 42 |
* | 1 | b | 100 |
* | 2 | owner | 0x1234 |
* +------+----------+--------+
*/
uint256 a;
uint256 b;
address owner;
}
// v2 inserts a new variable in the wrong slot
contract VaultV2 {
/* +------+----------+------------------------------+
* | slot | variable | value |
* +------+----------+------------------------------+
* | 0 | a | 42 |
* | 1 | c | 100 |
* | 2 | b | 4660 (converted from 0x1234) |
* | 3 | owner | 0x0 |
* +------+----------+------------------------------+
*/
uint256 a;
uint256 c; // <------- putting 'c' here causes corruption
uint256 b;
address owner;
}
After the upgrade, the owner
variable now reads from slot 3, instead of slot 2, which has a completely different value that expected, preventing the contract from ever being upgraded or generally, used correctly, again.
Storage collision in the wild
Storage collision is exactly what allowed an attacker to steal over 18.5M $AUDIO tokens, worth over $1M USD at the time, from Audius’ community treasury.
Audius team had overriden the OpenZeppelin’s proxy contract, in the form of their own AudiusAdminUpgradeabilityProxy
, to allow for more flexibility in their governance. However, they missed the fact that their new variable proxyAdmin
caused storage collision with OpenZeppelin’s Initializable
contract, which allowed the attacker to call the initializer
and take over the Audius governance and steal the tokens from the community treasury.
+--------------------+-------------------+
| Proxy | Implementation |
+--------------------+-------------------+
| address proxyAdmin | bool initialized |
| - | bool initializing |
+--------------------+-------------------+
You can read more about it in the post-mortem, but the TL;DR is:
Value stored in the first slot of the proxy’s storage was interpreted in a way that allowed the initializer()
modifier to always succeed, which allowed the attacker to modify the governance’s addresses and take control of the voting.
How does ERC-7201 solve storage corruption?
- Each logical module defines a unique namespace.
- All its variables live under that namespace's storage root.
- Upgrades append fields inside that struct, so even if you mess up, that mess would be contained within the corrupted namespace.
Naming a namespace
EIP-7201 proposes the following formula:
keccak256(abi.encode(uint256(keccak256(bytes(id))) - 1)) & ~bytes32(uint256(0xff))
// OR
keccak256(abi.encode( // 3) prevents conflicts with Solidity slots
uint256(keccak256(
bytes(id) // 1) identifier of the namespace
)) - 1) // 2) unused storage slot
)) & ~bytes32(uint256(0xff) // 4) flips last byte to 0
where:
id
represents the identifier of the namespace, in whichever format is desirable- good practice is to define it in a reverse DNS fashion, e.g.
projectName.contract.module
, so that they’re findable and predictable
- good practice is to define it in a reverse DNS fashion, e.g.
uint256(keccak256(bytes(id)) - 1)
computes a storage slot that’s unused by the Solidity compiler- another
keccak256(...)
prevents potential conflicts with slots generated by Solidity, since the location of dynamic size variables in storage is determined by akeccak256
hash - finally,
& ~bytes32(uint256(0xff))
simply flips the last byte to 0. Why? Because it’s a small optimization regarding warm storage, that will be useful once a future Ethereum upgrade brings Verkle trees into play.
Here’s how that looks in code:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
contract VaultV1 {
/// @notice The root slot of the vault storage namespace
/// @dev Equals to keccak256(abi.encode(uint256(keccak256(bytes(blog.vault.storage))) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant VAULT_STORAGE_SLOT = 0x087a21b00b7b34d0cf840eef16d8bf17cdbeabdaece1b68f53be6eda6829cf00;
// ...
}
By the way, I got the actual bytes32
value of the slot by using this small contract that I put into Remix, so that I can easily generate different slots:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
contract GenerateSlot {
function getSlot7201(string memory slotName) external pure returns (bytes32){
return keccak256(abi.encode(uint256(keccak256(bytes(slotName))) - 1)) & ~bytes32(uint256(0xff));
}
}
But how the hell do I use this slot?
Now that we have a location, we can set up a struct that will hold all of the logical module’s variables. That module’s storage will act the same as if it was your usual storage, just starting at VAULT_STORAGE_SLOT
, not 0x0
.
Read/write operations on that storage boil down to getting a pointer at VAULT_STORAGE_SLOT
, and working with it as usual!
// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
import { Initializable } from "@oz-upgradeable/proxy/utils/Initializable.sol";
contract VaultV1 is Initializable {
/// @notice The root slot of the vault storage namespace
/// @dev Equals to keccak256(abi.encode(uint256(keccak256(bytes(blog.vault.storage))) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant VAULT_V1_STORAGE_SLOT = 0x087a21b00b7b34d0cf840eef16d8bf17cdbeabdaece1b68f53be6eda6829cf00;
/// @custom:storage-location erc7201:projectName.vault.storage
struct StorageV1 {
uint256 a;
uint256 b;
address owner;
}
/// @notice Initializes an implementation contract
function initialize(uint256 a_, uint256 b_, address owner_) external initializer {
StorageV1 storage s = _getStorageV1();
// This initializer writes to storage at the namespace's starting location
s.a = a_;
s.b = b_;
s.owner = owner_;
}
/// @notice Allows writing to storage from a position=VAULT_STORAGE_SLOT
function _getStorage() internal pure returns (StorageV1 storage s) {
bytes32 slot = VAULT_STORAGE_SLOT;
assembly { s.slot := slot }
}
}
And there we go! Our StorageV1
lives at location VAULT_STORAGE_SLOT
and unless we mistakenly reorder variables within that namespace, our storage will be intact!
If you ever need to upgrade, simply append or add a new namespace:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
import { Initializable } from "@oz-upgradeable/proxy/utils/Initializable.sol";
import { VaultV1 } from "src/VaultV1.sol";
/// @notice V2 appends fields to the SAME namespace and slot as V1
contract VaultV2 is VaultV1, Initializable {
/// @custom:storage-location erc7201:blog.vault.storage
/// @dev Equals to keccak256(abi.encode(uint256(keccak256(bytes(blog.vault.storage))) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant VAULT_STORAGE_SLOT = 0x087a21b00b7b34d0cf840eef16d8bf17cdbeabdaece1b68f53be6eda6829cf00;
/// @custom:storage-location erc7201:blog.vault.someotherstorage
/// @dev Equals to keccak256(abi.encode(uint256(keccak256(bytes(blog.vault.someotherstorage))) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant VAULT_SOMEOTHERSTORAGE_SLOT = 0xc5d43c000acaf2bf1789cefe663810937309ed339d6f6609069bd19554c65100;
/// @dev V2 layout = V1 prefix + new fields appended at the end
struct StorageV2 {
uint256 a;
uint256 b;
address owner;
uint256 c; // new field
}
/// @dev New namespace for a new logical module
struct SomeOtherStorage {
uint256 d;
}
/// @notice V2 initializer writes ONLY the new fields
function initializeV2(uint256 c_, uint256 d_) external reinitializer(2) {
StorageV2 storage s = _getStorage();
s.c = c_;
SomeOtherStorage storage sos = _getSomeOtherStorage();
sos.d = d_;
}
/// @notice Allows writing to storage from a position=VAULT_STORAGE_SLOT
function _getStorage() internal pure returns (StorageV2 storage s) {
bytes32 slot = VAULT_STORAGE_SLOT;
assembly { s.slot := slot }
}
/// @notice Allows writing to storage from a position=VAULT_SOMEOTHERSTORAGE_SLOT
function _getSomeOtherStorage() internal pure returns (SomeOtherStorage storage s) {
bytes32 slot = VAULT_SOMEOTHERSTORAGE_SLOT;
assembly { s.slot := slot }
}
}
Best Practices
- Use
@custom:storage-location erc7201:id
to annotate the slot constant, so that NatSpec tools can leverage it for advanced docs in the future. - Only append variables to the struct, never reorder or delete fields.
initializer
for V1,reinitializer(N)
for V2+, which sets new field only.- Emit on init/upgrade for off-chain indexing.
- Pack small types (e.g.,
uint64
) in storage structs, when reasonable.