The Proxy Pattern in Solidity: From Zero to Hero — by Jay Makwana

Jay MakwanaJay Makwana
12 min read

Picture this: You’ve just deployed your amazing DeFi protocol to Ethereum mainnet. Users are depositing millions of dollars. Then you discover a critical bug. On traditional platforms, you’d push a quick update. On Ethereum? You’re basically stuck because smart contracts are immutable by design.

This is where proxy patterns come to the rescue — they’re like having a forwarding address for your mail. When you move houses, mail still reaches you because the post office forwards it to your new address. Similarly, proxy patterns let you “move” your smart contract logic while keeping the same address for users.

The Apartment Building Analogy

Think of a proxy pattern like an apartment building:

🏢 APARTMENT BUILDING (Proxy Contract)
├── 📬 Mailbox/Reception (Fallback Function)
├── 🏠 Apartment 3A (Implementation Contract v1)
├── 🏠 Apartment 3B (Implementation Contract v2)
└── 🏠 Apartment 3C (Implementation Contract v3)

Users always go to the same building address (proxy), but the receptionist (proxy logic) forwards them to whichever apartment (implementation) is currently active. When you need to upgrade, you just change which apartment the receptionist sends people to.

Why We Need Proxy Patterns

The Immutability Problem

Smart contracts have a superpower and a curse: once deployed, they can’t be changed. This is great for trust but terrible for bugs and upgrades.

Without Proxies:

Contract v1 → Bug found → Deploy v2 → Users must migrate → Lose network effects
     ↓
  💰 $10M TVL → 🚨 Critical bug → 🆕 New contract → 😢 Users confused

With Proxies:

Proxy Contract → Points to Implementation v1 → Bug found → Switch to v2 → Users unaffected
     ↓
  💰 $10M TVL → 🚨 Critical bug → 🔄 Seamless upgrade → 😊 Users happy

Real-World Benefits

For Users:

  • Same address, no confusion

  • Funds don’t need migration

  • Seamless experience during upgrades

For Developers:

  • Fix bugs without losing users

  • Add features progressively

  • Maintain network effects

For Protocols:

  • Preserve TVL during upgrades

  • Maintain integrations with other protocols

  • Keep token listings and reputation

Types of Proxy Patterns

1. Simple Proxy Pattern

The most basic approach — like having a simple forwarding service:

contract SimpleProxy {
    address public implementation;
    address public admin;

    constructor(address _implementation) {
        implementation = _implementation;
        admin = msg.sender;
    }

    // This is where the magic happens
    fallback() external payable {
        address impl = implementation;
        assembly {
            // Copy call data
            calldatacopy(0, 0, calldatasize())

            // Forward call to implementation
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)

            // Copy return data
            returndatacopy(0, 0, returndatasize())

            // Return or revert based on result
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    function upgrade(address newImplementation) external {
        require(msg.sender == admin, "Only admin can upgrade");
        implementation = newImplementation;
    }
}

Flow Diagram:

User calls proxy.transfer(100) 
        ↓
Proxy catches call in fallback()
        ↓
Proxy forwards call to implementation
        ↓
Implementation executes transfer logic
        ↓
Result forwarded back to user

2. Transparent Proxy Pattern

The simple proxy has a problem: what if the implementation contract has a function with the same name as the proxy’s admin functions? This creates collisions. The transparent proxy solves this:

contract TransparentProxy {
    address private implementation;
    address private admin;

    modifier onlyAdmin() {
        require(msg.sender == admin, "Only admin");
        _;
    }

    // Admin functions - only callable by admin
    function upgrade(address newImpl) external onlyAdmin {
        implementation = newImpl;
    }

    function getImplementation() external view returns (address) {
        require(msg.sender == admin, "Only admin can view");
        return implementation;
    }

    // Users can't call admin functions, admins can't call implementation
    fallback() external payable {
        require(msg.sender != admin, "Admin cannot fallback");
        _delegate(implementation);
    }
}

The Rule:

  • If you’re an admin → You can only call admin functions

  • If you’re a user → You can only call implementation functions

  • Never the two shall meet!

3. UUPS (Universal Upgradeable Proxy Standard)

UUPS flips the script — instead of the proxy controlling upgrades, the implementation contract controls its own upgrades:

// Implementation contract
contract MyTokenV1 is UUPSUpgradeable {
    function _authorizeUpgrade(address newImplementation) 
        internal 
        override 
        onlyOwner 
    {
        // Custom upgrade authorization logic
    }

    function version() public pure returns (string memory) {
        return "v1.0.0";
    }
}

Comparison Table:

Pattern Gas Cost Flexibility Complexity Best For Simple Low Limited Easy Learning/Testing Transparent Medium Good Medium Production Apps UUPS Lowest High Complex Gas-Sensitive Apps

Real-World Use Cases

1. DeFi Protocol Evolution

Compound Finance Example:

Compound V1 → V2 (Add new features)
     ↓
Keep same cToken addresses → Users don't need to migrate liquidity
     ↓
Seamless upgrade → Protocol maintains market position

2. NFT Marketplace Upgrades

OpenSea-Style Platform:

contract NFTMarketplaceV1 {
    function createListing(uint256 tokenId, uint256 price) external {
        // Basic listing logic
    }
}
contract NFTMarketplaceV2 {
    function createListing(uint256 tokenId, uint256 price) external {
        // Same function signature, enhanced logic
    }

    function createAuction(uint256 tokenId, uint256 startPrice) external {
        // New feature!
    }
}

Users keep using the same marketplace address, but suddenly auctions are available.

3. DAO Governance Systems

Evolution Path:

DAO v1: Basic voting
    ↓ (Proxy upgrade)
DAO v2: Add delegation
    ↓ (Proxy upgrade)  
DAO v3: Add quadratic voting
    ↓ (Proxy upgrade)
DAO v4: Add cross-chain governance

Storage Collision: The Silent Killer

Here’s where things get tricky. Both proxy and implementation contracts use the same storage space. It’s like two roommates sharing the same closet without coordination:

// ❌ WRONG: Storage collision
contract Proxy {
    address public implementation; // Slot 0
    address public admin;          // Slot 1
}
contract Implementation {
    uint256 public totalSupply;   // Slot 0 - COLLISION!
    address public owner;         // Slot 1 - COLLISION!
}

Visual Storage Layout:

Storage Slots:
┌─────────────────────────────────────┐
│ Slot 0: implementation (Proxy)      │ ← Collision!
│         totalSupply (Implementation)│
├─────────────────────────────────────┤
│ Slot 1: admin (Proxy)              │ ← Collision!
│         owner (Implementation)      │
└─────────────────────────────────────┘

The Solution: Storage Slots Pattern

Use specific storage slots for proxy variables:

contract SafeProxy {
    // Use a random slot far from slot 0
    bytes32 private constant IMPLEMENTATION_SLOT = 
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function _implementation() internal view returns (address impl) {
        bytes32 slot = IMPLEMENTATION_SLOT;
        assembly {
            impl := sload(slot)
        }
    }

    function _setImplementation(address newImpl) internal {
        bytes32 slot = IMPLEMENTATION_SLOT;
        assembly {
            sstore(slot, newImpl)
        }
    }
}

Advanced Patterns and Optimizations

1. Beacon Proxy Pattern

When you have multiple proxies that should all upgrade together:

BEACON CONTRACT
                    (stores impl address)
                           ↑
            ┌──────────────┼──────────────┐
            │              │              │
      PROXY A        PROXY B        PROXY C
     (Token 1)      (Token 2)      (Token 3)

Perfect for token factories where you want to upgrade all tokens simultaneously.

2. Diamond Pattern (EIP-2535)

For when your contract gets too big for a single implementation:

DIAMOND PROXY
      /      |      \
  Facet A  Facet B  Facet C
 (Trading) (Lending) (Staking)

Each facet handles different functionality, but users interact with one address.

3. Clone Factory Pattern

For creating many identical contracts cheaply:

contract CloneFactory {
    address public immutable implementation;

    function createClone(bytes32 salt) external returns (address clone) {
        clone = Clones.cloneDeterministic(implementation, salt);
        // Initialize the clone
        IMyContract(clone).initialize(msg.sender);
    }
}

Gas Comparison:

Full Contract Deployment: ~2,000,000 gas
Clone Deployment:         ~50,000 gas
Savings:                  ~97.5%! 🎉

Implementation Best Practices

1. Initialize, Don’t Construct

Implementation contracts can’t use constructors (they run when deployed, not when proxied):

// ❌ BAD: Constructor won't work with proxies
contract BadImplementation {
    address public owner;

    constructor() {
        owner = msg.sender; // This runs when deployed, not when proxied!
    }
}
// ✅ GOOD: Use initialize function
contract GoodImplementation {
    address public owner;
    bool private initialized;

    function initialize() external {
        require(!initialized, "Already initialized");
        owner = msg.sender;
        initialized = true;
    }
}

2. Storage Layout Inheritance

Keep storage layout consistent across versions:

// V1
contract TokenV1 {
    string public name;      // Slot 0
    string public symbol;    // Slot 1  
    uint256 public totalSupply; // Slot 2
}
// V2 - ONLY append new variables
contract TokenV2 {
    string public name;      // Slot 0 - Keep same!
    string public symbol;    // Slot 1 - Keep same!
    uint256 public totalSupply; // Slot 2 - Keep same!

    // New variables go at the end
    mapping(address => bool) public verified; // Slot 3
    uint256 public fee;      // Slot 4
}

3. Function Selector Management

Be careful about function signatures:

// These have the same selector - will cause conflicts!
function transfer(address to, uint256 amount) external {} 
// Selector: 0xa9059cbb
function transfer(address,uint256) external {}
// Selector: 0xa9059cbb (same!)

Security Considerations

1. Admin Key Management

The proxy admin key is incredibly powerful:

contract SecureProxy {
    address public admin;
    uint256 public constant UPGRADE_DELAY = 48 hours;

    mapping(address => uint256) public upgradeTimestamp;

    function proposeUpgrade(address newImpl) external onlyAdmin {
        upgradeTimestamp[newImpl] = block.timestamp + UPGRADE_DELAY;
        emit UpgradeProposed(newImpl, block.timestamp + UPGRADE_DELAY);
    }

    function executeUpgrade(address newImpl) external onlyAdmin {
        require(
            upgradeTimestamp[newImpl] != 0 && 
            block.timestamp >= upgradeTimestamp[newImpl],
            "Upgrade not ready"
        );

        implementation = newImpl;
        delete upgradeTimestamp[newImpl];
    }
}

2. Implementation Validation

Always validate new implementations:

function upgrade(address newImpl) external onlyAdmin {
    // Verify it's a contract
    require(newImpl.code.length > 0, "Not a contract");

    // Verify it supports the interface
    require(
        IERC165(newImpl).supportsInterface(type(IMyProtocol).interfaceId),
        "Invalid implementation"
    );

    implementation = newImpl;
}

Real-World Case Studies

Case Study 1: Aave Protocol

Aave uses proxy patterns extensively:

AAVE LENDING POOL PROXY
├── LendingPool Implementation v1 (Launch)
├── LendingPool Implementation v2 (Credit Delegation)
├── LendingPool Implementation v3 (Efficiency Improvements)
└── LendingPool Implementation v4 (Flash Loans 2.0)

Benefits Achieved:

  • Maintained $20B+ TVL through upgrades

  • Added major features without user migration

  • Fixed critical bugs seamlessly

Case Study 2: OpenZeppelin’s Approach

OpenZeppelin’s upgradeable contracts pattern:

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyTokenV1 is Initializable, ERC20Upgradeable, OwnableUpgradeable {
    function initialize(
        string memory name,
        string memory symbol
    ) public initializer {
        __ERC20_init(name, symbol);
        __Ownable_init();
    }
}

Case Study 3: Uniswap V3 Factory

Uniswap uses a clone factory pattern for pool creation:

UNISWAP V3 FACTORY
├── Pool Implementation (Template)
├── Clone: ETH/USDC Pool (0.3% fee)
├── Clone: ETH/USDC Pool (0.05% fee)  
├── Clone: WBTC/ETH Pool (0.3% fee)
└── Clone: DAI/USDC Pool (0.01% fee)

Result: Thousands of pools, minimal deployment costs.

Common Pitfalls and How to Avoid Them

1. The Selfdestruct Trap

// ❌ NEVER DO THIS in implementation contracts
contract BadImplementation {
    function emergencyExit() external {
        selfdestruct(payable(msg.sender)); // Will brick the proxy!
    }
}

What happens: The implementation gets destroyed, proxy becomes useless forever.

2. Direct Implementation Calls

// ❌ DON'T: Call implementation directly
MyToken implementation = MyToken(implementationAddress);
implementation.transfer(to, amount); // Bypasses proxy storage!
// ✅ DO: Always call through proxy
MyToken proxy = MyToken(proxyAddress);
proxy.transfer(to, amount); // Uses proxy storage correctly

3. Storage Initialization

// ❌ BAD: Uninitialized storage in V2
contract TokenV2 {
    mapping(address => uint256) public balances; // From V1
    uint256 public newFeature; // Starts at 0, might need initialization!

    function someFunction() external {
        require(newFeature > 0, "Will always fail!"); 
    }
}
// ✅ GOOD: Initialize new storage
contract TokenV2 {
    mapping(address => uint256) public balances;
    uint256 public newFeature;

    function initializeV2() external {
        require(newFeature == 0, "Already initialized");
        newFeature = 100; // Set proper initial value
    }
}

Gas Optimization Techniques

1. Minimal Proxy (EIP-1167)

For clone factories, use the minimal proxy pattern:

// Traditional clone: ~45,000 gas
// Minimal proxy: ~200 gas per call overhead
contract MinimalProxyFactory {
    function createClone(address target, bytes32 salt) 
        external 
        returns (address result) 
    {
        bytes20 targetBytes = bytes20(target);
        assembly {
            let clone := mload(0x40)
            mstore(clone, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
            mstore(add(clone, 0x14), targetBytes)
            mstore(add(clone, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
            result := create2(0, clone, 0x37, salt)
        }
    }
}

2. Storage Packing

Optimize storage layout in implementation contracts:

// ❌ Inefficient: Uses 3 storage slots
struct BadUser {
    address wallet;     // 20 bytes
    bool isActive;      // 1 byte  
    uint256 balance;    // 32 bytes
}
// ✅ Efficient: Uses 2 storage slots  
struct GoodUser {
    address wallet;     // 20 bytes
    bool isActive;      // 1 byte
    uint96 balance;     // 12 bytes (fits with address + bool)
}

Testing Proxy Contracts

Setup Test Environment

contract ProxyTest is Test {
    MyProxy proxy;
    MyImplementationV1 implV1;
    MyImplementationV2 implV2;

    function setUp() public {
        implV1 = new MyImplementationV1();
        proxy = new MyProxy(address(implV1));

        // Initialize through proxy
        MyImplementationV1(address(proxy)).initialize("MyToken", "MTK");
    }

    function testUpgrade() public {
        // Deploy V2
        implV2 = new MyImplementationV2();

        // Upgrade
        proxy.upgrade(address(implV2));

        // Test new functionality
        MyImplementationV2(address(proxy)).newFeature();
    }
}

Test Upgrade Scenarios

function testStoragePersistence() public {
    // Set state in V1
    MyImplementationV1(address(proxy)).mint(alice, 1000);

    // Upgrade to V2
    proxy.upgrade(address(implV2));

    // Verify state persists
    assertEq(MyImplementationV2(address(proxy)).balanceOf(alice), 1000);
}

Modern Tools and Frameworks

1. OpenZeppelin Upgrades

npm install @openzeppelin/hardhat-upgrades
# Deploy upgradeable contract
npx hardhat run scripts/deploy.js
# Upgrade contract  
npx hardhat run scripts/upgrade.js

2. Hardhat Deploy Plugin

// deploy/001_deploy_proxy.js
module.exports = async ({getNamedAccounts, deployments}) => {
    const {deploy} = deployments;
    const {deployer} = await getNamedAccounts();

    await deploy('MyToken', {
        from: deployer,
        proxy: {
            proxyContract: 'OpenZeppelinTransparentProxy',
            execute: {
                init: {
                    methodName: 'initialize',
                    args: ['MyToken', 'MTK']
                }
            }
        }
    });
};

When NOT to Use Proxies

Immutability is a Feature

Some contracts benefit from immutability:

  • Money protocols where trust is paramount

  • Governance tokens where tokenomics shouldn’t change

  • Time-locked contracts where immutability is the point

Gas-Sensitive Applications

Every proxy call adds overhead:

Direct call:     21,000 gas
Proxy call:      21,000 + 2,300 gas overhead
Difference:      ~10% increase

For high-frequency trading or ultra-gas-optimized contracts, this matters.

Simple, One-Off Contracts

If you’re building a simple contract that does one thing and will never change, proxies add unnecessary complexity.

The Future: What’s Coming Next

1. Account Abstraction Integration

contract SmartWalletProxy {
    function execute(Operation[] calldata ops) external {
        // Batch operations through upgradeable logic
        for (uint i = 0; i < ops.length; i++) {
            _delegateCall(implementation, ops[i].data);
        }
    }
}

2. Cross-Chain Proxy Patterns

ETHEREUM PROXY ←→ POLYGON PROXY ←→ ARBITRUM PROXY
     ↓                 ↓                ↓
  SHARED IMPLEMENTATION ACROSS CHAINS

3. Automated Governance Upgrades

contract DAOUpgradeableProxy {
    function proposeUpgrade(address newImpl) external {
        // Create governance proposal
        governanceToken.propose(
            "Upgrade to new implementation",
            address(this),
            abi.encodeCall(this.upgrade, newImpl)
        );
    }
}

Conclusion: Building for the Long Term

Proxy patterns aren’t just a technical curiosity — they’re essential infrastructure for building protocols that can evolve. In a world where user expectations change rapidly and bugs are inevitable, the ability to upgrade gracefully isn’t optional.

The key insight is that immutability and upgradeability aren’t opposites — they’re complementary. You want your core logic to be immutable (deployed as separate implementation contracts) while keeping the flexibility to evolve (through the proxy pattern).

Whether you’re building the next DeFi protocol, NFT marketplace, or DAO platform, understanding proxy patterns will help you create systems that can grow with your users’ needs while maintaining the trust and security that makes blockchain technology special.

Remember: great protocols aren’t just deployed — they evolve. Proxy patterns give you the technical foundation to build systems that can stand the test of time while adapting to an ever-changing ecosystem.


Ready to implement proxy patterns in your next project? Start with OpenZeppelin’s upgradeable contracts library — it handles most of the complexity for you. And remember, with great upgradeability comes great responsibility. Plan your upgrades carefully, test thoroughly, and always consider the security implications.

0
Subscribe to my newsletter

Read articles from Jay Makwana directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jay Makwana
Jay Makwana