Proxies & Upgradeability
29.1 Why proxies?
Contract code is immutable. Proxies separate state (in the proxy) from logic (in an implementation contract). The proxy's fallback delegatecalls every call to the implementation; upgrading means pointing the proxy to a new implementation.
Trade-offs: upgradeability adds trust assumptions (whoever controls upgrades controls user funds), complexity, and storage-collision risks. Use timelocks, multisigs, and governance for upgrade rights — or prefer immutable contracts where possible.
User ──call──► Proxy (storage, balance) ──delegatecall──► Implementation V1 (code only)
└── upgrade: implementation = V229.2 A minimal proxy (for understanding — use audited libraries in production)
ERC-1967 standardizes slots for the implementation, admin (keccak256("eip1967.proxy.admin") - 1) and beacon, so explorers can detect proxies and slots don't collide with the implementation's variables.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MinimalProxy {
// ERC-1967 implementation slot: bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
bytes32 private constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
constructor(address impl) {
assembly { sstore(IMPL_SLOT, impl) }
}
fallback() external payable {
assembly {
let impl := sload(IMPL_SLOT)
calldatacopy(0, 0, calldatasize())
let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
receive() external payable {}
}29.3 Patterns compared
Upgrade logic lives in Transparent (OZ TransparentUpgradeableProxy) proxy + ProxyAdmin admin can't accidentally call implementation; simple impl more gas per call (admin check), extra ProxyAdmin contract
UUPS (ERC-1822 / OZ UUPSUpgradeable) implementation (upgradeToAndCall) cheaper calls, smaller proxy, can remove upgradeability forgetting _authorizeUpgrade or deploying a non-UUPS impl can brick or expose the proxy
Beacon (BeaconProxy + UpgradeableBeacon) upgrade many proxies in one tx extra call to beacon per call Diamond (EIP-2535) diamond (diamondCut) many facets, unlimited size, granular upgrades complex, harder to audit and verify Minimal clones (ERC-1167) none (not upgradeable) very cheap deployment
29.4 UUPS with OpenZeppelin
Deploy with ERC1967Proxy(address(new BoxV1()), abi.encodeCall(BoxV1.initialize, (owner, 42))), or with the OpenZeppelin Upgrades plugins for Foundry/Hardhat which also validate storage-layout compatibility.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract BoxV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() { _disableInitializers(); } // lock the implementation itself
function initialize(address owner_, uint256 v) public initializer {
__Ownable_init(owner_);
value = v;
}
function setValue(uint256 v) external onlyOwner { value = v; }
function _authorizeUpgrade(address) internal override onlyOwner {} // REQUIRED access control
}
contract BoxV2 is BoxV1 {
function increment() external { value += 1; } // new function; keep existing storage order
function version() external pure returns (string memory) { return "2"; }
}29.5 Upgradeable-contract rules
No constructors for state — use initializer functions; call _disableInitializers() in the implementation's constructor.
Protect initialize from being called twice or front-run (deploy and initialize atomically).
Never reorder, remove or change the type of existing state variables; only append (or use ERC-7201 namespaces).
Don't use immutable for per-proxy values (immutables live in implementation code) unless intended.
No selfdestruct or delegatecall to arbitrary addresses in implementations.
Use reinitializer(n) for migration steps in new versions.
29.6 Diamond (EIP-2535) overview
A diamond maps each function selector to a facet address:
Facets share storage through "diamond storage" or "AppStorage" structs at fixed slots. Reference implementations: Nick Mudge's diamond-1/2/3.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
library LibDiamond {
bytes32 constant STORAGE_POSITION = keccak256("diamond.standard.diamond.storage");
struct DiamondStorage {
mapping(bytes4 => address) selectorToFacet;
address owner;
}
function diamondStorage() internal pure returns (DiamondStorage storage ds) {
bytes32 position = STORAGE_POSITION;
assembly { ds.slot := position }
}
}
contract Diamond {
constructor(address owner) { LibDiamond.diamondStorage().owner = owner; }
fallback() external payable {
address facet = LibDiamond.diamondStorage().selectorToFacet[msg.sig];
require(facet != address(0), "Diamond: function does not exist");
assembly {
calldatacopy(0, 0, calldatasize())
let ok := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
// A full diamond adds diamondCut(FacetCut[], address init, bytes calldata) and loupe functions
// (facets(), facetAddress
…