Upgradeable Smart Contracts in Solidity: Proxy Patterns Explained
A practical guide to upgradeable smart contracts in Solidity — how proxy patterns work, UUPS vs Transparent proxy, storage layout risks, and when upgradeability is worth it.
A proxy keeps a contract address and its stored state while delegating calls to replaceable implementation logic. That flexibility adds storage-layout, authorization, and governance risks that should be part of the design decision.
How Proxy Patterns Work in Solidity
A proxy contract is a minimal contract that holds state and forwards all calls to a separate implementation contract using delegatecall. Because delegatecall runs the implementation's code in the proxy's storage context, users interact with a stable address while the underlying logic can be swapped.
The core mechanism:
// Minimal proxy forwards all calls to the implementation
fallback() external payable {
address impl = _getImplementation();
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()) }
}
}
The proxy stores the implementation address in a specific storage slot and delegates everything else. The implementation contract contains the logic but holds no persistent state — all state lives in the proxy.
The Storage Layout Problem
Storage layout is a major risk in upgradeable contracts. Because delegatecall uses the proxy's storage but the implementation's logic, both contracts must agree on which variable sits at which storage slot.
If you upgrade to an implementation that declares variables in a different order, the new code reads the wrong values from storage:
// V1 implementation
uint256 public totalSupply; // slot 0
address public owner; // slot 1
// V2 implementation — BROKEN
address public owner; // slot 0 — reads totalSupply as an address
uint256 public totalSupply; // slot 1
This is silent corruption. The contract does not revert — it just reads wrong values. Preventing this is the primary engineering discipline of upgradeable contract development.
The rule: never change the order of existing state variables in an implementation contract. Only append new variables at the end. Never insert, remove, or reorder.
UUPS vs Transparent Proxy Pattern
OpenZeppelin provides two well-tested proxy patterns.
Transparent Proxy
The admin address is stored in the proxy itself. Calls from the admin address are handled by the proxy's admin logic; calls from any other address are forwarded to the implementation. This prevents function selector conflicts between the proxy and implementation.
The trade-off: the proxy is slightly more complex and every non-admin call goes through a check. The admin cannot interact with the implementation through the proxy — they must use a separate interface.
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
UUPS Proxy (Universal Upgradeable Proxy Standard)
In UUPS, the upgrade logic lives in the implementation contract rather than the proxy. The proxy itself is minimal — no admin logic, no selector checks. The implementation exposes an upgradeTo function that replaces the stored implementation address.
This approach is cheaper at runtime and gives the implementation full control over upgrade authorization. The risk: if you deploy a broken implementation that does not include the upgrade function, you lose the ability to upgrade forever.
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{}
Compare the upgrade authorization, storage layout, and tooling for the specific library version you plan to use. Neither pattern removes the need for review and upgrade testing.
Initializers Instead of Constructors
Constructors run in the context of the implementation contract's deployment, not the proxy. State set in a constructor is not visible through the proxy.
Upgradeable contracts use initialize() functions instead:
function initialize(address _owner) public initializer {
__Ownable_init(_owner);
__ReentrancyGuard_init();
// your initialization logic
}
The initializer modifier from OpenZeppelin ensures initialize() can only be called once. Never leave an implementation contract without calling its initializer — an uninitializated implementation can be attacked.
When to Use Upgradeable Contracts
Upgradeability is a meaningful trade-off, not a default to reach for. It introduces:
- Storage layout risk on every upgrade
- Centralization risk (whoever controls the upgrade key controls the protocol)
- Implementation complexity
- Audit surface that is harder to reason about
Use upgradeable contracts when:
- Your contract handles significant user funds and you need the ability to patch critical bugs
- Business logic is expected to change (not just a hedge against hypothetical bugs)
- You have a governance mechanism or multi-sig controlling upgrades, not a single EOA
Do not use upgradeable contracts when:
- The contract is simple and well-tested
- You want "upgradeability" but the contract is not complex enough to warrant the risk
- A migration pattern (deploy new contract, migrate state) is a viable alternative
Upgrade Safety Tooling
OpenZeppelin's Hardhat and Foundry upgrade plugins validate storage layout compatibility before allowing an upgrade:
npx hardhat run scripts/upgrade.ts --network mainnet
The plugin compares the storage layout of the new implementation against the current one and blocks the upgrade if it detects incompatible changes. Include compatibility checks in the upgrade procedure.
What Good Upgrade Governance Looks Like
An upgrade controlled by one private key creates a single point of failure. Evaluate multiple signers and a timelock against the protocol’s emergency response and governance requirements.
Document who can authorize an upgrade, how it is reviewed, and what happens during an emergency.
For help designing proxy storage and upgrade controls, contact LinkIT about smart contract development.