One storage namespace per module: ERC-7201 in practice
How to give each module of an upgradeable Solidity protocol its own ERC-7201 storage namespace: naming, the erc7201 builtin, safe struct changes and tests.
· 5 min read
- ERC-7201
- Storage layout
- Upgradeable contracts
An upgradeable contract’s storage layout is a promise every future implementation has to keep. With Solidity’s default layout, that promise covers the order of every variable in every base contract: add a base in the wrong place and everything after it moves. OpenZeppelin Contracts 4 handled this with __gap arrays reserved at the end of each contract. That works, but it is fragile and hard to review.
ERC-7201 namespaced storage replaced it. Each module keeps its state in a struct at its own hashed location, so modules can’t collide and inheritance order stops mattering for storage. Solidity 0.8.35’s erc7201 builtin now computes those locations for you. This article covers how we apply the pattern across a protocol: the accessor, naming, migrating, evolving structs and testing.
The pattern
A module declares a struct, annotates it with its namespace, and reaches it through a private accessor:
abstract contract RewardsModule {
/// @custom:storage-location erc7201:acme.vault.rewards
struct RewardsStorage {
uint256 rewardPerShare;
uint64 lastUpdate;
mapping(address account => uint256) claimed;
}
bytes32 private constant REWARDS_STORAGE = bytes32(erc7201("acme.vault.rewards"));
function _rewards() private pure returns (RewardsStorage storage $) {
bytes32 slot = REWARDS_STORAGE;
assembly {
$.slot := slot
}
}
}
Three details are worth copying exactly:
- The
@custom:storage-locationannotation. It has no effect on code generation, but the compiler keeps struct-level NatSpec in the AST, and upgrade tooling such as OpenZeppelin’s upgrades plugins reads it from there to compare namespaced structs between versions. The compiler’s storage layout output lists ordinary state variables only, so without the annotation tools can’t see the struct at all. - The local
slotvariable. Inline assembly only accepts constants whose value is a literal expression. A constant computed by a builtin call, such aserc7201orkeccak256, has to be copied into a local first. - A private accessor. Only this module can reach its state. Everything else calls the module’s internal functions.
The builtin needs Solidity 0.8.35 or later. We compile with 0.8.37, the current release and one with no known compiler bugs at the time of writing.
Naming namespaces
A namespace id is permanent: change it after deployment and the state is left behind at the old location. Choose ids that won’t need to change.
- Use
<organisation>.<protocol>.<module>, for exampleacme.vault.rewards. The organisation prefix avoids collisions with libraries that use the same pattern, such asopenzeppelin.storage.ERC20. - Don’t derive the id from the contract name. Contracts get renamed, and the namespace mustn’t follow.
- Don’t put a version in the id.
acme.vault.rewards.v2suggests that upgrading means moving to a new namespace, which strands the old state. - Never reuse an id. When a module is removed, its data stays in storage. A new module under the same id would read it.
Keep a list of every namespace in the repository, for example in a short markdown file next to the interfaces, and check new ones against it in review.
Moving from hardcoded slots to the builtin
Before 0.8.35, every namespaced module carried a hex constant computed off-chain. When you upgrade the compiler on an existing codebase, replace those constants with erc7201(...) calls. That removes the risk of a mistyped or wrongly computed hex value.
The change doesn’t alter what’s deployed. We compiled a module both ways, and apart from the metadata hash the optimised runtime bytecode was identical on both the legacy and IR pipelines. The slot is folded to a constant at compile time.
It doesn’t remove every risk, though. The id now appears twice, once in the annotation and once in the erc7201 call, and a typo in either goes unnoticed: OpenZeppelin’s tooling computes the location from the annotation, not from your code. Add a test per module that pins the slot to the deployed value:
function test_RewardsSlotUnchanged() public pure {
assertEq(
bytes32(erc7201("acme.vault.rewards")),
0x27c114ed0fb3a7702a1182bd69a237d85e432d11751d83d822eb527a3ed74d00
);
}
Also add a CI step that checks every annotation’s id matches the string passed to erc7201 in the same file. A short script is enough. Test files that call erc7201 also need 0.8.35 or later. Pin the version in foundry.toml.
Changing a namespaced struct
Namespaces protect modules from each other. They don’t protect a struct from its own changes, and the usual upgrade rules apply inside it:
- Append new fields at the end. This is always safe for the top-level struct, because nothing follows it in its namespace. New fields start at zero, so if they need an initial value, set it in a
reinitializer(n)function called during the upgrade. - Don’t remove, reorder, rename or retype fields. To retire a field, leave it with its original name and type and add a
// deprecatedcomment. OpenZeppelin’s@custom:oz-renamed-fromand@custom:oz-retyped-fromannotations work on top-level variables but not on struct members, because the compiler doesn’t keep NatSpec on struct members. The upgrades plugin will therefore flag any rename inside a namespaced struct. - Nested structs can’t grow. A struct field sits inline, so making it bigger moves every field after it. Growing the last field would be safe in storage, but the upgrades plugin still flags it.
- Structs stored in mappings can grow at the end. Each mapping value has its own hashed location, so extra fields don’t overlap the next entry.
- Structs stored in arrays can’t grow. Array elements are stored back to back, so a larger element overlaps the next one. If you expect the struct to change, use a mapping keyed by index instead.
Use @openzeppelin/upgrades-core 1.46.0 or later. Earlier versions computed some ERC-7201 locations incorrectly.
Testing that the layout holds
Upgrade tooling catches most layout mistakes, but we also test storage directly, because tests run on every commit and document intent.
Read raw slots after a write. Set a value through the public API, then read the namespace’s first slot directly:
function test_RewardsWrittenToNamespace() public {
vault.notifyReward(1e18);
bytes32 raw = vm.load(address(vault), bytes32(erc7201("acme.vault.rewards")));
assertEq(uint256(raw), vault.rewardPerShare());
}
Upgrade on a fork. For a live protocol, fork mainnet, upgrade the proxy to the new implementation, and run your invariant suite against the result. If the layout moved, balance and accounting invariants usually fail at once.
Account for every linear variable. forge inspect <Contract> storage-layout shows state variables outside namespaced structs. Each entry should be deliberate. In our codebases, that means either nothing, or a deployable contract’s own variables placed with layout at. An unexpected entry is a variable someone declared outside a namespace, and future upgrades will need to keep it in its slot.
Where layout at fits
Solidity 0.8.29’s layout at specifier shifts all of a contract’s ordinary state variables to one base slot, rather than giving each module its own. It suits a deployable contract’s own variables, and EIP-7702 delegation code, but within that region inheritance order still matters. We cover it in Solidity’s layout at and erc7201 explained.
Summary
- One module, one namespace, one private accessor.
- Ids follow
<organisation>.<protocol>.<module>, are listed in the repository, and are never changed or reused. - Use
erc7201(...)instead of hex constants, keep the annotation, and test that the two agree. - Append-only struct changes, with care around nested structs and arrays.
- Tests that read raw slots, plus a fork upgrade before every release.
For how modules fit into the rest of a codebase, see structuring a Solidity protocol codebase. If you’d like your protocol’s storage layout reviewed before an upgrade, see our Ethereum smart contract development service.