Future

Cover image for Contract Upgradeability: How to Build Protocols That Can Evolve Without Losing Trust
Ribhav
Ribhav

Posted on • Originally published at Medium

Contract Upgradeability: How to Build Protocols That Can Evolve Without Losing Trust

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:

  1. Bugs are permanent - If you deploy with a vulnerability, it stays there
  2. No feature updates - Can't add new functionality without deploying a new contract
  3. Migration hell - Moving users/funds to a new contract loses state and trust
  4. 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)
Enter fullscreen mode Exit fullscreen mode

When you need to upgrade:

  1. Deploy new logic contract
  2. Point proxy to new logic
  3. Keep all existing data in proxy
  4. 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()) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Good (Multi-sig):

address public constant MULTISIG = 0x...; // 3-of-5 Gnosis Safe
function upgradeTo(address newImpl) external {
    require(msg.sender == MULTISIG);
    implementation = newImpl;
}
Enter fullscreen mode Exit fullscreen mode

Better (Multi-sig + Timelock):

address public constant TIMELOCK = 0x...; // 48-hour delay
function upgradeTo(address newImpl) external {
    require(msg.sender == TIMELOCK);
    implementation = newImpl;
}
Enter fullscreen mode Exit fullscreen mode

Best (Governance):

contract GovernedProxy {
    IGovernance public governance;

    function upgradeTo(address newImpl) external {
        require(governance.hasApprovedUpgrade(newImpl));
        require(block.timestamp >= governance.upgradeTimestamp(newImpl));
        implementation = newImpl;
    }
}
Enter fullscreen mode Exit fullscreen mode

Real-world: Aave's governance process:

  1. AIP (Aave Improvement Proposal) submitted
  2. Community discussion (Governance Forum)
  3. Snapshot vote (sentiment check)
  4. On-chain vote (AAVE token holders)
  5. Timelock (48 hours minimum)
  6. 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);
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Upgrades

Test your upgrades before deploying to mainnet.

Test checklist:

  1. 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);
}
Enter fullscreen mode Exit fullscreen mode
  1. New functionality works
function testNewFeature() public {
    upgradeProxy(address(new TokenV2()));
    TokenV2 v2 = TokenV2(proxy);

    v2.newFeature(); // Should work
    assert(v2.featureEnabled());
}
Enter fullscreen mode Exit fullscreen mode
  1. Initialization can't be called twice
function testReinitialize() public {
    TokenV1 v1 = TokenV1(proxy);

    vm.expectRevert("Already initialized");
    v1.initialize(999);
}
Enter fullscreen mode Exit fullscreen mode
  1. Upgrade authorization works
function testUnauthorizedUpgrade() public {
    vm.prank(attacker);
    vm.expectRevert("Not admin");
    proxy.upgradeTo(address(maliciousImpl));
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Deploy new implementation on testnet
  2. Run security audits (3+ firms)
  3. Community testing period (weeks)
  4. AIP vote (AAVE token holders)
  5. 48-hour timelock activation
  6. Gradual rollout (deploy to low-TVL chains first)
  7. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Pause old contract
  2. Export all balances/state
  3. Deploy new contract with imported state
  4. 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 initializer modifier
  • [ ] 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:

  1. Go to "Contract" tab
  2. Look for "Read as Proxy" or "Write as Proxy"
  3. If you see it, they're using a proxy pattern
  4. Check who controls upgrades (admin address)
  5. 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


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)