Proxies and Upgradability - UUPS proxy (EIP-1822)
I’m currently open to collaborations and development projects across blockchain, smart contracts, and full-stack systems, feel free to connect if you’re building something interesting.
In the previous post, we explored the Transparent Proxy (EIP-1967) pattern where the proxy holds both the forwarding logic and the upgrade control plane.
That design works, but it comes with baggage: an extra admin contract, more bytecode, and slightly higher gas overhead.UUPS (Universal Upgradeable Proxy Standard, EIP-1822) takes a leaner approach.
It keeps the proxy as a minimal forwarder and moves the upgrade logic into the implementation contract itself.
Instead of callingupgradeToon the proxy, you call it on the implementation through the proxy.
This makes the proxy reusable, smaller, and easier to reason about, while each implementation defines its own upgrade rules.In this post, you’ll learn:
How UUPS differs from the Transparent Proxy pattern
The role of
proxiableUUIDand thekeccak256(”PROXIABLE”)storage slotHow to deploy and upgrade a UUPS proxy end-to-end with Foundry
By the end, you’ll understand how modern protocols (and OpenZeppelin’s
UUPSUpgradeablecontracts) achieve upgradability with less overhead and the trade-offs that come with that simplicity.
UUPS proxy (EIP-1822)
Transparent proxies are fine, but they have an extra baggage: you have to maintain both the proxy contract and the upgrade admin logic. UUPS (Universal Upgradeable Proxy Standard) flips that design.
UUPS proxies instead of making the proxy contract responsible for upgrades, UUPS pushes that responsibility into the implementation contract. The proxy itself is just a “dumb forwarder” that delegates all calls. The implementation contains a special function (usually called proxiableUUID or upgradeTo) that knows how to upgrade to a new version. This keeps the proxy very small and reusable, while each implementation can define its own rules for who’s allowed to upgrade.
the address of the Logic Contract is stored at the defined storage position keccak256(”PROXIABLE”)=0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7.
Key differences:
The proxy is slimmer: no upgrade code inside it.
Each implementation contract must include an upgrade function (and usually OpenZeppelin’s
UUPSUpgradeablemixin).Storage layout still matters: if you change storage order between upgrades, you’ll corrupt state.
Example:
UUPSLogicContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
// The proxy
contract Proxiable {
// Code position in storage is keccak256(”PROXIABLE”) = “0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7”
function updateCodeAddress(address newAddress) internal {
require(
bytes32(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7) == Proxiable(newAddress).proxiableUUID(),
“Not compatible”
);
assembly { // solium-disable-line
sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, newAddress)
}
}
function proxiableUUID() public pure returns (bytes32) {
return 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7;
}
}
// Controls that only owner can do changes
contract Owned {
address owner;
function setOwner(address _owner) internal {
owner = _owner;
}
modifier onlyOwner() {
require(msg.sender == owner, “Only owner is allowed to perform this action”);
_;
}
}
contract LibraryLockDataLayout {
bool public initialized = false;
}
// Locking mechaninsm.
contract LibraryLock is LibraryLockDataLayout {
// Ensures no one can manipulate the Logic Contract once it is deployed.
// PARITY WALLET HACK PREVENTION
modifier delegatedOnly() {
require(initialized == true, “The library is locked. No direct ‘call’ is allowed”);
_;
}
function initialize() internal {
initialized = true;
}
}
contract ERC20DataLayout is LibraryLockDataLayout {
uint256 public totalSupply;
mapping(address=>uint256) public tokens;
}
contract MyToken is Owned, ERC20DataLayout, Proxiable, LibraryLock {
function constructor1(uint256 _initialSupply) public {
totalSupply = _initialSupply;
tokens[msg.sender] = _initialSupply;
initialize();
setOwner(msg.sender);
}
function updateCode(address newCode) public onlyOwner delegatedOnly {
updateCodeAddress(newCode);
}
function transfer(address to, uint256 amount) public delegatedOnly {
require(tokens[msg.sender] >= amount, “Not enough funds for transfer”);
tokens[to] += amount;
tokens[msg.sender] -= amount;
}
}Note: A lot of precautions added here, the main contract is MyToken that inherits Proxiable. other inherits keep the contract safe from different possible attacks.
UUPSProxy.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
/*
* Very simplified UUPS pattern.
* - Proxy stores state and delegates calls.
* - Implementation holds upgrade logic.
*/
// ---------------- Proxy ----------------
contract UUPSProxy {
// Code position in storage is keccak256(”PROXIABLE”) = “0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7”
constructor(bytes memory constructData, address contractLogic) {
// save the code address
assembly {
sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, contractLogic)
}
// call the constructor
(bool success, ) = contractLogic.delegatecall(constructData);
require(success, “Construction failed”);
}
// This fallback will actually call the logic contract
// because every function with databytes will arrive here
fallback() external payable {
assembly {
// load the logic contract address
let contractLogic := sload(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7)
calldatacopy(0x0, 0x0, calldatasize())
// call the logic contract with the databytes
let success := delegatecall(sub(gas(), 10000), contractLogic, 0x0, calldatasize(), 0, 0)
let retSz := returndatasize()
returndatacopy(0, 0, retSz)
switch success
case 0 {
revert(0, retSz)
}
default {
return(0, retSz)
}
}
}
}Lets spin the node:
anviland deploy the two contracts from a different terminal
// Deploy the contract logic
forge create src/UUPSLogicContract.sol:MyToken --rpc-url localhost:8545 --private-key <YOUR-ANVIL-PRIVATE-KEY> --broadcast
// expected output, something like:
// [⠊] Compiling...
// [⠒] Compiling 1 files with Solc 0.8.30
// [⠆] Solc 0.8.30 finished in 69.50ms
// Compiler run successful!
// Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
// Transaction hash: 0xfd7f81e7a54dba55a1a6399e465ed9ff67dc0d95a3f2fbbf85542fd7b0ffdf81
// Deploy the UUPS proxy
INIT_DATA=$(cast calldata “constructor1(uint256)” 1000000)
forge create src/UUPSProxy.sol:UUPSProxy --rpc-url localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --broadcast --constructor-args “$INIT_DATA” 0x5FbDB2315678afecb367f032d93F642f64180aa3
// expected output, something like:
// [⠊] Compiling...
// No files changed, compilation skipped
// Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// Deployed to: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
// Transaction hash: 0x60c04bf75c647a9c5bcd73155c8906675247ccf4f25fab20bf76e1b5a4ef129eNow lets call the auto-generated getter on MyToken contract via the proxy:
// Note that 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 is the proxy address
cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 “totalSupply()(uint256)” --rpc-url http://localhost:8545Summary
UUPS proxies strip upgradeability down to its simplest form.
The proxy only knows how to forward calls. The implementation handles upgrades through its own updateCode (or upgradeTo) function, secured by access control.
This pattern reduces bytecode size, deployment cost, and complexity but it also puts more responsibility on the implementation contract. A single mistake in upgrade logic can brick the system, which is why production frameworks (like OpenZeppelin’s UUPSUpgradeable) wrap it with guards and the ERC-1967 slot standard.
Understanding UUPS gives you a deeper sense of how real protocols balance immutability with flexibility, and how much power (and risk) lives behind a single delegatecall.

