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


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.
Subscribe to my newsletter
Read articles from Jay Makwana directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
