Upgradeable Smart Contracts: UUPS, Storage, and ERC-7201
You deploy a smart contract. Users deposit funds. Then you find a bug.
What now?
Smart contracts are immutable. Once deployed, the bytecode can't change. You can't patch it. You can't hotfix it. The code is permanently etched into the blockchain.
Or is it?
The Proxy Pattern
The solution is indirection. Instead of changing the contract, you change what the contract points to.
A proxy is a minimal contract that forwards all calls to another contract called the implementation. Users interact with the proxy. The proxy delegates to the implementation. When you need to upgrade, you deploy a new implementation and update the proxy's pointer.
Before upgrade:
User → Proxy → Implementation v1
After upgrade:
User → Proxy → Implementation v2
The proxy address never changes. User balances, approvals, and integrations all stay intact. Only the logic changes.
But here's the critical part: the proxy doesn't just call the implementation. It uses something called delegatecall.
What Is delegatecall?
Solidity has two ways to execute code from another contract: call and delegatecall.
Regular call:
Contract A calls Contract B
├─ Executes B's code
├─ Reads/writes B's storage
└─ msg.sender = A
delegatecall:
Contract A delegatecalls Contract B
├─ Executes B's code
├─ Reads/writes A's storage ← Key difference
└─ msg.sender = original caller
With delegatecall, you're saying: "Run this other contract's code, but use MY storage."
This is what makes proxies work. The proxy holds all the state (balances, configs, mappings). The implementation holds all the logic (transfer functions, validation, calculations). When you call the proxy, it delegatecalls the implementation, executing the logic against the proxy's storage.
In practice, you don't write proxy contracts from scratch. OpenZeppelin provides ERC1967Proxy, a battle-tested proxy with the forwarding logic built in.
The proxy has a fallback() function (a special function that runs when no other function matches) that forwards every call to your implementation via delegatecall. You don't write this - OpenZeppelin handles it.
How EVM Storage Works
Before we go further, you need to understand how the EVM stores data.
Every contract has its own storage, organized as 2^256 slots, each holding 32 bytes. Think of it as a giant key-value store where keys are slot numbers (0, 1, 2, ...) and values are 32-byte words.
When you declare state variables:
contract Example {
uint256 public totalSupply; // Slot 0
address public owner; // Slot 1
mapping(address => uint256) public balances; // Slot 2 (base)
}
Solidity assigns sequential slots. Simple variables get one slot each. Mappings and arrays use a hash of the slot number plus the key to compute where each element lives.
Important: Each contract has its own storage. Slot 0 in Contract A is completely different from Slot 0 in Contract B. They're separate namespaces.
Storage Costs
Reading and writing storage are the most expensive EVM operations. From EIP-2200 and EIP-2929:
| Operation | Gas Cost |
|---|---|
| SLOAD (read) | 2,100 gas |
| SSTORE (zero → non-zero) | ~20,000 gas |
| SSTORE (non-zero → non-zero) | ~5,000 gas |
This cost structure influences how we design upgradeable contracts.
The Storage Collision Problem
Here's where things get dangerous.
With proxies, the implementation's code runs against the proxy's storage. If the implementation expects owner in slot 1 but the proxy has something else in slot 1, you get storage collision.
// Proxy storage layout
contract Proxy {
address public implementation; // Slot 0
address public admin; // Slot 1
}
// Implementation storage layout
contract ImplementationV1 {
uint256 public totalSupply; // Slot 0 ← Collides with implementation!
address public owner; // Slot 1 ← Collides with admin!
}
When the implementation writes to totalSupply, it overwrites the proxy's implementation address. The proxy now points to garbage. Protocol bricked.
This also happens between upgrades:
// V1 layout
contract V1 {
uint256 public totalSupply; // Slot 0
address public owner; // Slot 1
}
// V2 layout (WRONG - reordered!)
contract V2 {
address public owner; // Slot 0 ← Was totalSupply!
uint256 public totalSupply; // Slot 1
uint256 public newField; // Slot 2
}
After upgrading V1 → V2, owner now reads from what was totalSupply. Your admin becomes a random number.
ERC-7201: Namespaced Storage Layout
EIP-7201 solves storage collisions by computing storage slots from unique namespace strings.
Why do you need this? OpenZeppelin's upgradeable contracts use certain storage slots internally. Your implementation contracts might accidentally use the same slots. ERC-7201 gives each contract (or module) its own isolated storage region that won't collide with anything.
Implementing ERC-7201
Instead of declaring state variables directly, you put them in a struct and store it at a computed slot:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MyProtocol {
// ERC-7201 namespace: "myprotocol.storage.main"
// Computed via: keccak256(abi.encode(uint256(keccak256("myprotocol.storage.main")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant STORAGE_SLOT =
0x1234...; // Your computed slot
struct MainStorage {
mapping(address => uint256) balances;
uint256 totalSupply;
address owner;
uint256[50] __gap; // Reserved for upgrades
}
function _getStorage() private pure returns (MainStorage storage $) {
assembly {
$.slot := STORAGE_SLOT
}
}
function deposit() external payable {
MainStorage storage $ = _getStorage();
$.balances[msg.sender] += msg.value;
$.totalSupply += msg.value;
}
}
Computing the Slot
The formula:
keccak256(abi.encode(uint256(keccak256(bytes(namespace))) - 1)) & ~bytes32(uint256(0xff))
You can compute it with a Foundry script:
contract ComputeSlot is Script {
function run() public pure {
string memory namespace = "myprotocol.storage.main";
bytes32 slot = keccak256(
abi.encode(uint256(keccak256(bytes(namespace))) - 1)
) & ~bytes32(uint256(0xff));
console.log("Slot:", vm.toString(slot));
}
}
Or use OpenZeppelin's Foundry Upgrades plugin which handles this automatically.
Why the Weird Formula?
Subtract 1 - Avoids colliding with Solidity's standard storage tree locations.
Hash twice - Ensures separation from mapping/array slot derivations (keccak256(key . slot)).
Zero last byte (& ~0xff) - Reserves 256 sequential slots for your struct fields. Field 0 at slot 0x...00, field 1 at 0x...01, etc.
Implementing in Practice
Here's a complete example for a lending protocol:
library ProtocolStorageLib {
// The computed slot from "myprotocol.storage.Main"
bytes32 internal constant MAIN_STORAGE_SLOT =
0xa1b2c3d4e5f6071829304050607080900a1b2c3d4e5f60718293040506070800;
// All protocol state lives in this struct
struct MainStorage {
mapping(address => uint256) balances;
mapping(uint256 => Position) positions;
uint256 positionCounter;
address oracle;
uint256[48] __gap; // Reserved slots for future upgrades
}
function _getStorage() internal pure returns (MainStorage storage $) {
assembly {
$.slot := MAIN_STORAGE_SLOT
}
}
}
What's assembly? Solidity compiles to EVM bytecode, but sometimes you need lower-level control. The assembly block lets you write Yul, a low-level language that maps directly to EVM opcodes. It's like writing assembly code for a CPU.
Why use it here? Standard Solidity can't directly set which storage slot a variable points to. The line $.slot := LENDING_STORAGE_SLOT tells the EVM: "When I access $, read and write from this specific slot." This isn't possible in pure Solidity.
What's __gap? When you upgrade a contract, you might want to add new fields to the struct. But if you add them at the end, they'd overflow into the next namespace's slots. The __gap reserves 48 empty slots. To add a new field, you remove one slot from the gap: uint256[47] __gap. The struct grows without colliding with anything.
Every function that needs protocol state calls the storage getter:
function createPosition(...) external {
// Get a pointer to our storage struct
ProtocolStorageLib.MainStorage storage $ = ProtocolStorageLib._getStorage();
// Now use it like any other struct
$.positionCounter++;
$.positions[$.positionCounter] = Position(...);
}
What does storage mean in the variable declaration? Solidity has two main data locations: storage (permanent, on-chain, expensive) and memory (temporary, in RAM, cheap). When you write LendingStorage storage $, you're saying "this is a reference to data in storage, not a copy." Changes to $ directly modify on-chain state.
Why the $ name? It's just a convention. The $ character is a valid variable name in Solidity, and it's visually distinct. When you see $.something, you immediately know it's accessing namespaced storage. You could name it s or storage_ or anything else, but $ has become the standard in upgradeable contracts.
Verifying No Collisions
Use a script to compute all slots and check uniqueness:
contract ComputeSlots is Script {
string constant MAIN_NAMESPACE = "myprotocol.storage.Main";
string constant ORACLE_NAMESPACE = "myprotocol.storage.Oracle";
string constant REWARDS_NAMESPACE = "myprotocol.storage.Rewards";
function run() public pure {
bytes32 mainSlot = computeSlot(MAIN_NAMESPACE);
bytes32 oracleSlot = computeSlot(ORACLE_NAMESPACE);
bytes32 rewardsSlot = computeSlot(REWARDS_NAMESPACE);
// Verify no collisions
require(mainSlot != oracleSlot, "COLLISION");
require(mainSlot != rewardsSlot, "COLLISION");
require(oracleSlot != rewardsSlot, "COLLISION");
}
}
Probability of collision: 1 in 2^248. You'll win the lottery a trillion times before you see one.
UUPS vs Transparent Proxy
There are two main proxy patterns: Transparent Proxy and UUPS (Universal Upgradeable Proxy Standard, based on OpenZeppelin's implementation).
| Transparent Proxy | UUPS | |
|---|---|---|
| Upgrade logic location | Proxy contract | Implementation contract |
| Gas per transaction | Higher (checks admin on every call) | Lower (minimal proxy) |
| Upgrade function | In proxy | upgradeTo() in implementation |
| Risk | None | Forgetting _authorizeUpgrade() bricks upgrades |
OpenZeppelin recommends UUPS for most use cases due to gas efficiency. The rest of this post focuses on UUPS.
Implementing UUPS with OpenZeppelin
Here's the practical setup. You'll need @openzeppelin/contracts-upgradeable:
# Foundry
forge install OpenZeppelin/openzeppelin-contracts-upgradeable
# Hardhat
npm install @openzeppelin/contracts-upgradeable
Step 1: Write Your Implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address initialOwner) external initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function setValue(uint256 _value) external {
value = _value;
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
Let's break down the key parts:
Initializable - Provides the initializer modifier that ensures initialize() can only be called once.
UUPSUpgradeable - Gives you upgradeTo(address) and upgradeToAndCall(address, bytes) functions. You must override _authorizeUpgrade().
OwnableUpgradeable - Access control for who can upgrade. You could also use AccessControlUpgradeable for role-based permissions.
_disableInitializers() in constructor - Critical safety measure. Prevents anyone from calling initialize() directly on the implementation contract (which would let them become owner of the implementation).
__Ownable_init() and __UUPSUpgradeable_init() - OpenZeppelin's upgradeable contracts can't use constructors. These __ContractName_init() functions set up their internal state.
_authorizeUpgrade() - Controls who can upgrade. Here it's onlyOwner. In production, use a timelock or multisig.
Step 2: Deploy with a Proxy
With Foundry, deploy using OpenZeppelin's ERC1967Proxy:
// deploy/DeployMyContract.s.sol
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {MyContractV1} from "../src/MyContractV1.sol";
contract DeployMyContract is Script {
function run() external returns (address proxy) {
vm.startBroadcast();
// 1. Deploy implementation
MyContractV1 implementation = new MyContractV1();
// 2. Encode initializer call
bytes memory initData = abi.encodeCall(
MyContractV1.initialize,
(msg.sender) // initialOwner
);
// 3. Deploy proxy pointing to implementation
ERC1967Proxy proxyContract = new ERC1967Proxy(
address(implementation),
initData // Calls initialize() atomically
);
vm.stopBroadcast();
proxy = address(proxyContract);
}
}
After deployment:
- Proxy address - This is what users interact with. Never changes.
- Implementation address - Can be upgraded. Users don't interact with this directly.
Step 3: Upgrade to V2
Write your new implementation:
contract MyContractV2 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public value;
uint256 public newValue; // New field added at the END
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
// No initialize() needed for upgrades - state already exists
function setValue(uint256 _value) external {
value = _value;
}
function setNewValue(uint256 _newValue) external {
newValue = _newValue;
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
Important: Add new storage variables at the END. Never reorder or remove existing variables.
Deploy V2 and call upgradeTo():
contract UpgradeMyContract is Script {
function run(address proxy) external {
vm.startBroadcast();
// 1. Deploy new implementation
MyContractV2 newImplementation = new MyContractV2();
// 2. Upgrade proxy to point to new implementation
MyContractV1(proxy).upgradeTo(address(newImplementation));
vm.stopBroadcast();
}
}
The proxy now delegates to V2. All existing state (like value) is preserved. Users call the same proxy address as before.
When Do You Need ERC-7201?
The simple UUPS setup above works for most contracts. You need ERC-7201 namespaced storage (explained earlier) when:
- Your protocol has multiple contracts that share storage patterns
- You're building a modular system where different logic contracts operate on the same storage
- You want extra protection against storage collisions with OpenZeppelin internals
For a single upgradeable contract with straightforward state, the basic UUPS pattern is enough.
The onlyProxy Modifier
There's one more protection you should add to implementation contracts: the onlyProxy modifier.
This ensures functions can only execute via delegatecall, not through direct calls:
contract Implementation {
address private immutable __self = address(this);
error OnlyProxy();
modifier onlyProxy() {
if (address(this) == __self) revert OnlyProxy();
_;
}
function sensitiveFunction() external onlyProxy {
// ...
}
}
How it works:
__selfcaptures the implementation's address at deployment- When called directly:
address(this) == __self→ reverts - When called via delegatecall:
address(this) == proxy address→ passes
What's immutable? Variables marked immutable are set once (in the constructor or at declaration) and can never change. Unlike regular state variables stored in storage slots, immutables are embedded directly in the contract's bytecode. This matters because delegatecall doesn't affect bytecode, only storage. So __self always returns the implementation's address, even during a delegatecall.
Direct call to implementation:
├─ address(this) = 0xImpl...
├─ __self = 0xImpl...
├─ Equal → REVERT
Delegatecall from proxy:
├─ address(this) = 0xProxy... (delegatecall context)
├─ __self = 0xImpl... (immutable)
├─ Not equal → PASS
Why Bother?
You might wonder: if direct calls to the implementation write to the implementation's storage (not the proxy's), aren't they harmless anyway?
Technically, yes. The implementation has its own isolated storage. Direct calls don't affect the proxy's state.
But onlyProxy is still best practice:
- Explicit security model - Auditors don't need to reason about "harmless but looks vulnerable"
- Defense in depth - If architecture changes, the protection is already there
- Prevents accidents - Nobody deploys the implementation as the main contract by mistake
- Industry standard - OpenZeppelin's UUPS includes it
It's the difference between "this door leads nowhere" and "this door is locked." Both secure, but one is clearer.
Real-World Example: A Bug Bounty Report
This isn't theoretical. Here's a common bug bounty scenario:
"Method
updateConfig()is missing access control. Anyone can update protocol params."
The reporter sees a logic module:
// LogicModule.sol
function updateConfig(
uint256 newFee,
uint256 newThreshold
) external { // No modifier!
ProtocolStorageLib.MainStorage storage $ = ProtocolStorageLib._getStorage();
$.config.fee = newFee;
$.config.threshold = newThreshold;
}
No onlyOwner. No role check. Looks vulnerable.
But LogicModule is never called directly. It's a delegatecall target:
// In the main contract (behind UUPS proxy)
function updateConfig(...) external onlyRole(ADMIN_ROLE) {
logicModule.functionDelegateCall(
abi.encodeCall(ILogicModule.updateConfig, (...))
);
}
The access control lives at the entry point. The logic module just holds logic.
If you call updateConfig() directly on the deployed LogicModule:
- The code runs
- It writes to
LogicModule's storage at slot0xa1b2c3... - The protocol reads from the proxy's storage at slot
0xa1b2c3... - Different contracts, different storage, no effect on protocol state
This is a common false positive in audits. But adding onlyProxy prevents the confusion entirely:
contract LogicModule {
address private immutable __self = address(this);
error OnlyProxy();
modifier onlyProxy() {
if (address(this) == __self) revert OnlyProxy();
_;
}
function updateConfig(...) external onlyProxy {
// Now direct calls revert explicitly
}
}
Same security outcome. Cleaner failure mode.
The Complete Architecture
Here's how it all fits together:
User calls updateConfig() on Proxy
│
▼
ERC1967 Proxy (minimal, just delegates)
│
├─► delegatecall
│
▼
MainContract (UUPS implementation)
│
├─► Access check: onlyRole(ADMIN_ROLE)
│ └─ Reverts if unauthorized
│
├─► Gets storage: ProtocolStorageLib._getStorage()
│ └─ Returns pointer to slot 0xa1b2c3... in PROXY's storage
│
├─► delegatecall to LogicModule
│
▼
LogicModule (logic module)
│
├─► onlyProxy check: address(this) != __self
│ └─ Passes (we're in proxy context)
│
├─► Executes logic against PROXY's storage
│
└─► Config updated
Storage lives in the proxy. Logic lives in implementations. Access control at entry points. ERC-7201 prevents collisions. onlyProxy prevents direct calls.
Takeaways
Proxies separate storage from logic. The proxy holds state; implementations hold code. delegatecall bridges them.
ERC-7201 prevents storage collisions. Hash namespace strings into slots guaranteed to be unique. Run the collision check script. Sleep well.
UUPS is more gas efficient than Transparent Proxy. Upgrade checks only happen when upgrading, not on every call.
Add onlyProxy to implementation contracts. Direct calls are usually harmless, but explicit protection is cleaner.
Understand the architecture before reporting bugs. The difference between a false positive and a real vulnerability is understanding how the pieces fit together.