# Proxy Patterns Explained — Transparent, UUPS, and Beacon
Smart contracts are immutable by default. Once deployed, code cannot change. Proxy patterns solve this by separating storage from logic, enabling upgrades. Let's explore the three main patterns.
How Proxies Work
Basic Concept
// Proxy (never changes)
contract Proxy {
address public implementation;
fallback() external payable {
address impl = implementation;
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()) }
}
}
}
// Implementation V1
contract LogicV1 {
uint256 public value;
function setValue(uint256 newValue) external {
value = newValue;
}
}
// Implementation V2 (upgraded)
contract LogicV2 {
uint256 public value;
function setValue(uint256 newValue) external {
value = newValue * 2; // New logic
}
function getValue() external view returns (uint256) {
return value; // New function
}
}
Users interact with the Proxy address. The Proxy delegatecalls to the implementation, which runs in the Proxy's storage context.
Pattern 1: Transparent Proxy
How It Works
The proxy has two types of callers:
contract TransparentProxy {
address public implementation;
address public admin;
modifier ifAdmin() {
if (msg.sender == admin) {
_;
} else {
_fallback();
}
}
function upgradeTo(address newImplementation) external ifAdmin {
implementation = newImplementation;
}
function changeAdmin(address newAdmin) external ifAdmin {
admin = newAdmin;
}
fallback() external payable {
_fallback();
}
function _fallback() internal {
address impl = implementation;
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()) }
}
}
}
Key Feature: Function Clashing Prevention
If the implementation has an upgradeTo() function, users can't call it — only admin can.
OpenZeppelin Implementation
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
// Deploy
ProxyAdmin proxyAdmin = new ProxyAdmin();
TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
address(implementationV1),
address(proxyAdmin),
"" // initialization data
);
// Upgrade
proxyAdmin.upgrade(proxy, address(implementationV2));
Pros & Cons
Pros:
- Simple mental model
- Admin can't accidentally call implementation functions
- Widely adopted
Cons:
- Extra gas cost (admin check on every call)
- Requires separate ProxyAdmin contract
Pattern 2: UUPS (Universal Upgradeable Proxy Standard)
How It Works
The upgrade logic lives in the implementation, not the proxy.
// Minimal UUPS Proxy
contract UUPSProxy {
address public implementation;
constructor(address _implementation) {
implementation = _implementation;
}
fallback() external payable {
address impl = implementation;
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()) }
}
}
}
// UUPS Implementation
contract UUPSImplementation {
address public owner;
uint256 public value;
function initialize(address _owner) external {
require(owner == address(0), "Already initialized");
owner = _owner;
}
function setValue(uint256 newValue) external {
value = newValue;
}
// Upgrade function (in implementation!)
function upgradeTo(address newImplementation) external {
require(msg.sender == owner, "Not owner");
// Update implementation slot
assembly {
sstore(0, newImplementation)
}
}
}
OpenZeppelin UUPS
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyContractV1 is UUPSUpgradeable, OwnableUpgradeable {
uint256 public value;
function initialize() public initializer {
__Ownable_init();
__UUPSUpgradeable_init();
}
function setValue(uint256 newValue) external {
value = newValue;
}
// Required by UUPS
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{}
}
// Deploy
ERC1967Proxy proxy = new ERC1967Proxy(
address(implementationV1),
abi.encodeWithSelector(MyContractV1.initialize.selector)
);
// Upgrade
MyContractV1 proxyAsV1 = MyContractV1(address(proxy));
proxyAsV1.upgradeTo(address(implementationV2));
Pros & Cons
Pros:
- Lower gas (no admin check)
- Simpler proxy (logic in implementation)
- More flexible (implementation controls upgrade rules)
Cons:
- Risk: If you deploy an implementation without
upgradeTo(), contract is bricked
- More complex implementation code
Pattern 3: Beacon Proxy
How It Works
Multiple proxies point to a single Beacon contract, which stores the implementation address.
// Beacon
contract UpgradeableBeacon {
address public implementation;
address public owner;
event Upgraded(address indexed implementation);
constructor(address _implementation) {
owner = msg.sender;
implementation = _implementation;
}
function upgradeTo(address newImplementation) external {
require(msg.sender == owner, "Not owner");
implementation = newImplementation;
emit Upgraded(newImplementation);
}
}
// Beacon Proxy
contract BeaconProxy {
address immutable public beacon;
constructor(address _beacon) {
beacon = _beacon;
}
fallback() external payable {
address impl = UpgradeableBeacon(beacon).implementation();
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()) }
}
}
}
Use Case: Factory Patterns
contract WalletFactory {
UpgradeableBeacon public beacon;
constructor(address implementation) {
beacon = new UpgradeableBeacon(implementation);
}
function createWallet() external returns (address) {
BeaconProxy proxy = new BeaconProxy(address(beacon));
return address(proxy);
}
// Upgrade ALL wallets at once
function upgradeAll(address newImplementation) external {
beacon.upgradeTo(newImplementation);
}
}
OpenZeppelin Beacon
import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
// Deploy beacon
UpgradeableBeacon beacon = new UpgradeableBeacon(address(implementationV1));
// Deploy multiple proxies
BeaconProxy proxy1 = new BeaconProxy(address(beacon), "");
BeaconProxy proxy2 = new BeaconProxy(address(beacon), "");
// Upgrade all at once
beacon.upgradeTo(address(implementationV2));
Pros & Cons
Pros:
- Upgrade multiple contracts with one transaction
- Lower deployment cost (proxies are simpler)
- Centralized upgrade logic
Cons:
- Extra indirection (beacon lookup adds gas)
- Single point of failure (beacon compromise = all proxies)
Comparison Table
| Feature | Transparent | UUPS | Beacon |
|---------|------------|------|--------|
| Upgrade logic | Proxy | Implementation | Beacon |
| Gas cost (calls) | High | Low | Medium |
| Deployment cost | Medium | Low | Low (per proxy) |
| Multi-upgrade | No | No | Yes |
| Bricking risk | Low | Medium | Low |
| Complexity | Low | Medium | High |
Storage Collisions
The Problem
// Proxy storage
contract Proxy {
address public implementation; // Slot 0
address public admin; // Slot 1
}
// Implementation storage
contract Logic {
uint256 public value; // Slot 0 (COLLISION!)
}
When Logic writes to value, it overwrites implementation in the proxy!
Solution: ERC-1967 Storage Slots
Use random slots for proxy variables:
// ERC-1967 standard
bytes32 constant IMPLEMENTATION_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
function _getImplementation() 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)
}
}
This guarantees no collision with normal storage.
Initialization (Not Constructors!)
Proxies can't use constructors — use initialize() instead:
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyContract is Initializable {
address public owner;
uint256 public value;
// NOT constructor!
function initialize(address _owner, uint256 _value)
public
initializer // Ensures called only once
{
owner = _owner;
value = _value;
}
}
Safe Upgrade Checklist
// V1
uint256 public a;
uint256 public b;
// V2 — OK
uint256 public a;
uint256 public b;
uint256 public c; // Append only
// V2 — WRONG
uint256 public b; // Reordered!
uint256 public a;
uint256 public c;
// V1
uint256 public value;
// V2 — WRONG
address public value; // Type changed!
contract MyContractV1 {
uint256 public value;
uint256[49] private __gap; // Reserve 49 slots
}
contract MyContractV2 {
uint256 public value;
uint256 public newValue; // Uses 1 gap slot
uint256[48] private __gap; // 48 slots remaining
}
forge script script/Upgrade.s.sol --rpc-url sepolia
Which Pattern to Choose?
Use Transparent if:
- You want the simplest, most battle-tested solution
- Gas cost is not critical
- You're uncomfortable with UUPS complexity
Use UUPS if:
- Gas optimization matters
- You want implementation-controlled upgrade logic
- You're comfortable with the bricking risk (mitigate with tests)
Use Beacon if:
- You're deploying many instances (factory pattern)
- You want to upgrade all at once
- You have a trusted upgrade controller
Real-World Examples
- USDC: Transparent proxy (prioritizes safety)
- Aave V3: Transparent proxy (audited pattern)
- Uniswap V3: Immutable (no upgrades)
- Compound: Transparent proxy with timelock
- 1inch: UUPS (gas-conscious)
Key Takeaways
Proxies enable upgrades, but introduce complexity. Use them when necessary, but consider immutability as the default.