The $10 Billion Dilemma
In Q1 2026, Aave is preparing to launch V4, a complete protocol redesign that will transform how $10+ billion in assets are managed across chains. The challenge? They need to upgrade the core logic without disrupting users, losing funds, or breaking trust.
This is the upgradeability problem every serious Web3 protocol faces: smart contracts are immutable by design, but protocols need to evolve.
The solution? Proxy patterns.
Aave V4 will use a "hub-and-spoke" architecture to unite liquidity across networks. Compound upgraded from V2 to V3 (Compound III) with a complete redesign. Both used upgradeable contracts to evolve without starting from scratch.
This is Day 37 of the 60-Day Web3 journey, still in Phase 3: Development. We've covered deployment safety (Day 35) and gas optimization (Day 36). Today we tackle the hardest architectural decision: how to build protocols that can adapt without breaking.
Upgradeability isn't optional for serious protocols. It's infrastructure.
Come hang out in Web3ForHumans on Telegram. Follow me on Medium | Twitter | Future
The Immutability Problem
Why contracts are immutable:
- Code on Ethereum can't be changed after deployment
- This is a feature, not a bug (trust through transparency)
- Users know exactly what the contract will do, forever
But this creates real problems:
- Bugs are permanent - If you deploy with a vulnerability, it stays there
- No feature updates - Can't add new functionality without deploying a new contract
- Migration hell - Moving users/funds to a new contract loses state and trust
- Competitive disadvantage - Can't iterate while competitors ship updates
Real-world example:
- Uniswap V1 (2018): Immutable, worked perfectly for its time
- Uniswap V2 (2020): New features required entirely new deployment
- Uniswap V3 (2021): Another full redeployment
- Uniswap V4 (2024): Introduced hooks but still immutable core
Each version required users to migrate liquidity. Billions in TVL had to move. Not ideal.
Aave's approach:
- Aave V1, V2, V3: Separate deployments
- Aave V4 (Q1 2026): Using upgradeable contracts to iterate faster without migration
Proxy Patterns Explained
Proxy patterns separate logic (the code) from state (the data).
The basic idea:
User → Proxy Contract (stores data) → Logic Contract (executes code)
When you need to upgrade:
- Deploy new logic contract
- Point proxy to new logic
- Keep all existing data in proxy
- Users interact with same address
Key insight: The proxy uses delegatecall to execute logic in the context of the proxy's storage.
The Three Main Proxy Patterns
1. Transparent Proxy Pattern
How it works:
- Proxy contract distinguishes between admin and user calls
- Admin calls go to proxy (for upgrades)
- User calls are delegated to logic contract
Structure:
contract TransparentProxy {
address public implementation; // Logic contract address
address public admin; // Who can upgrade
fallback() external payable {
if (msg.sender == admin) {
// Admin functions (upgrade, changeAdmin)
// Execute in proxy context
} else {
// User functions
// Delegate to implementation
_delegate(implementation);
}
}
function _delegate(address impl) internal {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
Pros:
- Clear separation between admin and user functions
- No function clashing possible
- Well-tested pattern (OpenZeppelin standard)
Cons:
- Extra gas cost on every call (checking if msg.sender is admin)
- Admin operations are more expensive
When to use: Default choice for most protocols. Aave V3 uses this.
2. UUPS (Universal Upgradeable Proxy Standard)
How it works:
- Upgrade logic lives in the implementation contract (not proxy)
- Proxy is minimal, just delegates everything
- Implementation must include upgrade function
Structure:
// Minimal proxy
contract UUPSProxy {
address public implementation;
fallback() external payable {
_delegate(implementation);
}
}
// Implementation with upgrade logic
contract UUPSImplementation {
address public implementation; // Stored in same slot as proxy
function upgradeTo(address newImplementation) external onlyAdmin {
implementation = newImplementation;
}
// Your contract logic here
}
Pros:
- Lower gas costs (no admin check on every call)
- Flexible upgrade logic (can add timelocks, governance in implementation)
- Smaller proxy bytecode
Cons:
- If you forget to include upgrade function in new implementation, contract becomes immutable forever
- More complex to implement correctly
When to use: When gas optimization matters and you have strong upgrade governance. EIP-1822 standard.
3. Beacon Proxy Pattern
How it works:
- Multiple proxies point to a single "beacon"
- Beacon stores the implementation address
- Upgrade beacon → all proxies upgrade simultaneously
Structure:
contract Beacon {
address public implementation;
address public owner;
function upgrade(address newImplementation) external {
require(msg.sender == owner);
implementation = newImplementation;
}
}
contract BeaconProxy {
address public beacon;
fallback() external payable {
address impl = Beacon(beacon).implementation();
_delegate(impl);
}
}
Pros:
- Upgrade many contracts at once
- Gas efficient for managing multiple instances
- Clear central control point
Cons:
- Single point of failure (beacon upgrade affects all proxies)
- Less flexible per-proxy control
When to use: When you deploy many instances of the same contract (e.g., token factories, lending pools). Aave V4's "hub-and-spoke" likely uses this.
Storage Collisions: The Silent Killer
The danger:
Proxy and implementation share the same storage slots. If not managed correctly, you'll overwrite data.
Bad example:
// Proxy V1
contract Proxy {
address public implementation; // Slot 0
}
// Implementation V1
contract TokenV1 {
uint256 public totalSupply; // Slot 0 - COLLISION!
}
When TokenV1 tries to set totalSupply, it overwrites the proxy's implementation address. Contract is bricked.
OpenZeppelin's solution: Unstructured Storage
Store proxy variables at random storage slots that won't collide with implementation.
contract Proxy {
// keccak256("eip1967.proxy.implementation") - 1
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
function _implementation() internal view returns (address impl) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
impl := sload(slot)
}
}
}
This slot is so random, implementation contracts will never use it naturally.
For implementation contracts:
Use OpenZeppelin's storage gap pattern:
contract TokenV1 {
uint256 public totalSupply;
mapping(address => uint256) public balances;
// Reserve 50 slots for future variables
uint256[50] private __gap;
}
contract TokenV2 is TokenV1 {
uint256 public newFeature; // Uses slot after __gap
// Reduce gap by 1
uint256[49] private __gap;
}
Upgrade Authorization & Governance
Never use a single EOA for upgrades.
Remember Day 35's access control lesson? It applies 10x to upgradeability.
Bad:
address public owner = 0x123...; // Single wallet
function upgradeTo(address newImpl) external {
require(msg.sender == owner);
implementation = newImpl;
}
Good (Multi-sig):
address public constant MULTISIG = 0x...; // 3-of-5 Gnosis Safe
function upgradeTo(address newImpl) external {
require(msg.sender == MULTISIG);
implementation = newImpl;
}
Better (Multi-sig + Timelock):
address public constant TIMELOCK = 0x...; // 48-hour delay
function upgradeTo(address newImpl) external {
require(msg.sender == TIMELOCK);
implementation = newImpl;
}
Best (Governance):
contract GovernedProxy {
IGovernance public governance;
function upgradeTo(address newImpl) external {
require(governance.hasApprovedUpgrade(newImpl));
require(block.timestamp >= governance.upgradeTimestamp(newImpl));
implementation = newImpl;
}
}
Real-world: Aave's governance process:
- AIP (Aave Improvement Proposal) submitted
- Community discussion (Governance Forum)
- Snapshot vote (sentiment check)
- On-chain vote (AAVE token holders)
- Timelock (48 hours minimum)
- Execution (upgrade happens)
This took weeks to months, intentionally. Slow upgrades = trusted upgrades.
Using OpenZeppelin's Upgrade Plugins
OpenZeppelin provides tools for Hardhat and Foundry to manage upgrades safely.
For Hardhat:
// scripts/deploy-proxy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const TokenV1 = await ethers.getContractFactory("TokenV1");
const proxy = await upgrades.deployProxy(TokenV1, [initialSupply], {
initializer: "initialize",
});
await proxy.deployed();
console.log("Proxy deployed at:", proxy.address);
}
Upgrading:
// scripts/upgrade-proxy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const proxyAddress = "0x123...";
const TokenV2 = await ethers.getContractFactory("TokenV2");
await upgrades.upgradeProxy(proxyAddress, TokenV2);
console.log("Proxy upgraded to V2");
}
The plugin automatically:
- Checks for storage layout collisions
- Validates initialization functions
- Warns about dangerous patterns
For Foundry:
OpenZeppelin recently added Foundry support. Use their scripts:
// script/DeployProxy.s.sol
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "../src/TokenV1.sol";
contract DeployProxy is Script {
function run() external {
vm.startBroadcast();
// Deploy implementation
TokenV1 impl = new TokenV1();
// Deploy proxy
bytes memory data = abi.encodeWithSelector(
TokenV1.initialize.selector,
initialSupply
);
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), data);
vm.stopBroadcast();
console.log("Proxy:", address(proxy));
}
}
Testing Upgrades
Test your upgrades before deploying to mainnet.
Test checklist:
- Storage layout compatibility
function testStorageLayout() public {
// Deploy V1
TokenV1 v1 = TokenV1(proxy);
v1.mint(alice, 1000);
// Upgrade to V2
upgradeProxy(address(new TokenV2()));
TokenV2 v2 = TokenV2(proxy);
// Verify storage persisted
assertEq(v2.balanceOf(alice), 1000);
}
- New functionality works
function testNewFeature() public {
upgradeProxy(address(new TokenV2()));
TokenV2 v2 = TokenV2(proxy);
v2.newFeature(); // Should work
assert(v2.featureEnabled());
}
- Initialization can't be called twice
function testReinitialize() public {
TokenV1 v1 = TokenV1(proxy);
vm.expectRevert("Already initialized");
v1.initialize(999);
}
- Upgrade authorization works
function testUnauthorizedUpgrade() public {
vm.prank(attacker);
vm.expectRevert("Not admin");
proxy.upgradeTo(address(maliciousImpl));
}
When to Use Upgradeable vs Immutable
Use upgradeable contracts when:
- Protocol is complex and likely needs iteration (Aave, Compound)
- Handling significant TVL (billions at risk)
- Governance-driven protocol (DAO controls upgrades)
- Regulatory requirements may change
- Long-term vision requires flexibility
Use immutable contracts when:
- Simple, well-understood logic (basic ERC-20 token)
- Maximum trust through transparency (Bitcoin-style ethos)
- Gas efficiency is critical (extra delegatecall costs matter)
- Governance overhead isn't worth it
- Risk of upgrade bugs > risk of being stuck
Examples:
- Upgradeable: Aave V4, Compound III, MakerDAO, Synthetix
- Immutable: Uniswap V3 core, most ERC-20 tokens, simple NFT contracts
Security Risks of Upgradeability
Risk 1: Admin key compromise
If upgrade admin is compromised, attacker can drain all funds.
Mitigation:
- Multi-sig (3-of-5 or higher)
- Timelock (48+ hours)
- Governance (community vote)
Risk 2: Storage collision bugs
Accidentally overwriting critical storage.
Mitigation:
- Use OpenZeppelin upgrade plugins (auto-check)
- Manual storage layout audits
- Comprehensive upgrade tests
Risk 3: Initialization front-running
Attacker calls initialize() before you do.
Mitigation:
constructor() {
_disableInitializers(); // OpenZeppelin pattern
}
Risk 4: Malicious upgrade
Admin deploys malicious implementation.
Mitigation:
- Governance + timelock (community can react)
- Emergency pause mechanisms
- Bug bounties for upgrade review
Real-World Example: Aave V4 (2026)
The challenge:
Aave manages $10+ billion across 15+ chains. Liquidity is fragmented. Users can't access the deepest pools.
The solution (V4):
- Hub-and-spoke architecture using Beacon Proxy pattern
- Central "Liquidity Hub" on each network holds pooled liquidity
- Multiple "spoke" contracts (lending pools) point to hub
- Upgrade hub → all spokes upgrade simultaneously
Why upgradeability matters here:
- Can't migrate $10B in one transaction
- Users would lose positions/rewards during migration
- Cross-chain coordination requires iterative improvements
- Regulatory changes across jurisdictions need quick response
Aave's upgrade process:
- Deploy new implementation on testnet
- Run security audits (3+ firms)
- Community testing period (weeks)
- AIP vote (AAVE token holders)
- 48-hour timelock activation
- Gradual rollout (deploy to low-TVL chains first)
- Monitor for 2+ weeks before mainnet Ethereum
Result: Protocol can evolve without breaking user trust or losing funds.
Migration Strategies
Even with upgradeability, sometimes you need full migration.
Strategy 1: Dual Deployment
Run old and new versions simultaneously.
contract MigrationHelper {
TokenV1 public oldToken;
TokenV2 public newToken;
function migrate() external {
uint256 balance = oldToken.balanceOf(msg.sender);
oldToken.burn(msg.sender, balance);
newToken.mint(msg.sender, balance);
}
}
Example: Synthetix migrated from V2 to V3 over several months.
Strategy 2: Snapshot Migration
Take snapshot of old state, deploy new contract with that state.
Process:
- Pause old contract
- Export all balances/state
- Deploy new contract with imported state
- Redirect users to new contract
Example: Compound V2 → Compound III (Comet).
Strategy 3: Gradual Incentive Migration
Incentivize users to move naturally.
Tactics:
- Higher yields on new contract
- Exclusive features on V2
- Airdrops for early migrators
- Partner integrations (UI defaults to new version)
Example: Uniswap V2 → V3 took 18+ months, liquidity gradually shifted.
Gas Optimization for Proxies
Remember Day 36's gas optimization? Proxies add overhead.
Transparent Proxy overhead:
- Admin check: ~2,100 gas per call
- delegatecall: ~700 gas
- Total: ~2,800 gas per transaction
UUPS overhead:
- delegatecall only: ~700 gas
- Savings: ~2,100 gas per call
At 1 gwei (Feb 2026): Negligible difference ($0.003)
At 500 gwei (bull market): ~$1.50 per transaction
For high-frequency contracts (DEX swaps), this matters. That's why Uniswap V3 stayed immutable.
Common Upgradeability Mistakes
Mistake 1: Forgetting to disable initializers
// BAD - Anyone can initialize
contract Token {
function initialize(address owner) external {
_owner = owner;
}
}
// GOOD - Constructor disables
contract Token is Initializable {
constructor() {
_disableInitializers();
}
function initialize(address owner) external initializer {
_owner = owner;
}
}
Mistake 2: Adding storage variables in wrong order
// V1
contract TokenV1 {
uint256 public totalSupply;
mapping(address => uint256) public balances;
}
// V2 - BAD (inserted in middle)
contract TokenV2 is TokenV1 {
uint256 public newVar; // DON'T DO THIS HERE
uint256 public totalSupply; // Shifts everything
}
// V2 - GOOD (appended at end)
contract TokenV2 is TokenV1 {
uint256 public newVar; // Correct - added after inherited vars
}
Mistake 3: Using constructor in implementation
// BAD - Constructor never runs for proxy
contract Token {
address public owner;
constructor() {
owner = msg.sender; // Won't work!
}
}
// GOOD - Use initializer
contract Token is Initializable {
address public owner;
function initialize() external initializer {
owner = msg.sender; // Works
}
}
Mistake 4: Not testing storage persistence
Always test that data survives upgrades.
function testUpgradePreservesData() public {
// Deploy V1
TokenV1 v1 = TokenV1(address(proxy));
v1.mint(alice, 1000);
v1.mint(bob, 500);
// Snapshot state
uint256 aliceBalanceBefore = v1.balanceOf(alice);
uint256 totalSupplyBefore = v1.totalSupply();
// Upgrade to V2
proxy.upgradeTo(address(new TokenV2()));
TokenV2 v2 = TokenV2(address(proxy));
// Verify persistence
assertEq(v2.balanceOf(alice), aliceBalanceBefore);
assertEq(v2.totalSupply(), totalSupplyBefore);
}
Upgradeability Checklist
Before implementing upgradeability:
Architecture
- [ ] Chosen proxy pattern (Transparent, UUPS, or Beacon)
- [ ] Documented storage layout
- [ ] Used OpenZeppelin Upgradeable contracts as base
- [ ] Implemented storage gaps for future variables
- [ ] Used unstructured storage for proxy variables
Security
- [ ] Multi-sig for upgrade admin (minimum 3-of-5)
- [ ] Timelock on upgrades (minimum 48 hours)
- [ ] Emergency pause mechanism
- [ ] Governance process documented
- [ ] Initializers protected with
initializermodifier - [ ] Constructor disables initializers
Testing
- [ ] Storage layout compatibility tests
- [ ] Upgrade simulation tests (V1 → V2 → V3)
- [ ] Initialization tests (can't call twice)
- [ ] Authorization tests (unauthorized can't upgrade)
- [ ] Data persistence tests (balances survive upgrade)
- [ ] New functionality tests (V2 features work)
Deployment
- [ ] Deployed to testnet first
- [ ] Security audits completed (2+ firms for serious protocols)
- [ ] Community testing period (1+ week)
- [ ] Upgrade transaction prepared and verified
- [ ] Monitoring setup (watch for issues post-upgrade)
- [ ] Rollback plan documented
Key Takeaway
Upgradeability is a trade-off: flexibility vs simplicity.
Immutable contracts are transparent and trustless. Users know exactly what the code will do forever. But they can't adapt to bugs, new features, or changing requirements.
Upgradeable contracts can evolve. But they require:
- Complex architecture (proxies, storage management)
- Strong governance (multi-sig, timelocks, community votes)
- Rigorous testing (storage collisions, upgrade paths)
- Security audits (upgrade mechanism is an attack vector)
The decision matrix:
| Factor | Immutable | Upgradeable |
|---|---|---|
| Trust model | Maximum (code is law) | Requires admin trust |
| Flexibility | None (deploy new contract) | High (iterate quickly) |
| Gas cost | Lower (direct calls) | Higher (+2,800 gas/tx) |
| Complexity | Simple | Complex |
| Best for | Simple protocols, max trust | Complex protocols, iteration needed |
Examples:
- Simple ERC-20 token? Probably immutable
- DeFi protocol managing billions? Definitely upgradeable
- NFT collection? Immutable
- Governance system? Upgradeable
- DEX core (like Uniswap)? Immutable
- Lending protocol (like Aave)? Upgradeable
Aave V4, Compound III, MakerDAO, and most serious DeFi protocols choose upgradeability because the flexibility is worth the complexity.
But Uniswap, most tokens, and simple contracts choose immutability because trust and simplicity matter more.
Your choice depends on your protocol's needs, not what's "better".
Think About This
Look at your favorite DeFi protocol on Etherscan. Check if it's upgradeable:
- Go to "Contract" tab
- Look for "Read as Proxy" or "Write as Proxy"
- If you see it, they're using a proxy pattern
- Check who controls upgrades (admin address)
- Is it a multi-sig? A timelock? Direct EOA?
The upgrade mechanism tells you more about protocol trust than the whitepaper does.
A protocol with billions in TVL and a single EOA admin? Red flag.
A protocol with governance + timelock + multi-sig? Green flag.
What's Next
You now understand how to build protocols that can evolve without breaking trust. Day 38 will cover multi-contract systems and composability, how to architect protocols where multiple contracts work together, and how to design for interoperability with other protocols.
We're 37 days into this 60-day journey. The next few days complete your development toolkit:
- Day 38: Multi-contract systems & composability
- Day 39: Comprehensive testing strategies
- Day 40: Frontend integration basics
Then we shift to building: implementing a DAO voting system, token with vesting, and real production deployments.
Resources
- OpenZeppelin Proxy Patterns - Official documentation
- OpenZeppelin Upgrades Plugins - Hardhat and Foundry tools
- EIP-1967: Standard Proxy Storage Slots - Technical spec
- EIP-1822: UUPS Standard - UUPS specification
- Cyfrin: Proxy Pattern Guide - Detailed tutorial
- Aave V4 Announcement - Real-world upgrade example
- Compound III Documentation - Migration case study
- OpenZeppelin Upgradeable Contracts - Battle-tested implementations
Come hang out in Web3ForHumans on Telegram. Follow me on Medium | Twitter | Future
Read the previous articles:
Want to try this yourself? Clone OpenZeppelin's upgrades repository, deploy a simple proxy on testnet, upgrade it to V2, and verify that storage persisted. Then try breaking it (add storage variable in wrong place) to see what happens. Share your findings in the Telegram community!
Top comments (0)