Smart Contract Upgrades (Upgradeable Proxy Pattern)
Contract deployed, bug found. In normal situation this is catastrophe — bytecode in blockchain is immutable. Proxy pattern solves this, separating contract address (what user sees) from logic (which can be replaced). But this is where the most interesting security questions begin.
Storage Collision — Most Dangerous Proxy Implementation Error
Classical proxy works via DELEGATECALL: proxy contract calls implementation, but executes code in proxy's storage context. Storage in EVM — array of 2²⁵⁶ slots of 32 bytes each. Proxy stores implementation address in slot 0. Implementation stores, for example, owner in slot 0.
Result — storage collision: owner in implementation overwrites implementation address in proxy. If attacker can call function that modifies owner in implementation, they gain control of proxy.
EIP-1967 solves this radically: stores implementation address in pseudo-random slot, computed as keccak256("eip1967.proxy.implementation") - 1. Probability of collision with user variables in implementation — astronomically small. OpenZeppelin ERC1967Proxy implements exactly this standard.
Three Main Patterns
Transparent Proxy (TUP). OpenZeppelin classic. Two types of callers: admin (manages upgrade) and users (call logic). Admin can't call implementation functions — only upgrade. Users can't call admin functions. Implemented via ifAdmin modifier in proxy. Overhead per call — one additional storage read to check msg.sender.
UUPS (EIP-1822). Upgrade logic moved to implementation contract itself. Proxy became thinner — less gas per call. But here's critical trap: if you deploy new implementation without upgradeTo function, contract loses upgrade capability forever. OpenZeppelin UUPSUpgradeable adds _authorizeUpgrade check — this is only protection.
Beacon Proxy. One beacon contract stores implementation address. Multiple proxy contracts point to this beacon. One beacon upgrade — all proxies update simultaneously. Perfect for factories (factory pattern), where need to create many identical contracts (e.g., pools in AMM).
| Pattern | Gas per Call | Flexibility | Risks |
|---|---|---|---|
| Transparent | +2100 gas (SLOAD) | High | Storage collision on wrong layout |
| UUPS | Minimal | High | Loss of upgradability on error |
| Beacon | Medium | Maximum for factories | Single point of failure (beacon) |
Initialization Instead of Constructor
constructor() in Solidity executes once on deployment. With proxy pattern, implementation deploys separately — its constructor executes in implementation context, not proxy. All variables set in constructor remain in implementation and inaccessible via proxy.
Solution: replace constructor with initialize() function with initializer modifier from OpenZeppelin. Called once via proxy, writes data to proxy storage.
Typical mistake — forget to call _disableInitializers() in implementation constructor. Without it, attacker can call initialize() directly on implementation (not via proxy) and become its owner. This doesn't directly impact proxy, but opens attack vectors via DELEGATECALL.
How We Implement Upgrades
All development — via Hardhat + OpenZeppelin Upgrades Plugin or Foundry with custom scripts.
OpenZeppelin Upgrades Plugin checks storage layout automatically: if new implementation violates layout of previous one (adds variable before existing ones, not after), plugin throws error before deployment. This is critical — layout violation discovered not in tests, but in production, when balanceOf starts returning garbage.
Example working workflow:
-
npx hardhat run scripts/deploy-proxy.ts— deploy proxy + implementation + ProxyAdmin -
npx hardhat run scripts/upgrade.ts— deploy new implementation, callupgrade()on ProxyAdmin - Verification:
getImplementation()returns new address, proxy storage unchanged
For Foundry we use custom script with vm.startBroadcast(), explicit address saving to JSON file (deployments/{chainId}.json) and verification on Etherscan via forge verify-contract.
Timelock and Multisig on Upgrade
Right to upgrade contract — is right to change rules for all users. In DeFi protocols with TVL above $1M, giving this right to single EOA (Externally Owned Account) unacceptable.
Standard scheme: Gnosis Safe (multisig 3-of-5) as ProxyAdmin owner + TimelockController with 48-72 hour delay. Upgrade goes through three stages: propose → await timelock → execute. Users see pending upgrade and have time to withdraw funds.
OpenZeppelin Governor + TimelockController — for protocols with DAO governance: upgrade passes through token holder voting.
Process
Audit existing contract. If contract already in production and need to add upgradability — this is harder than designing from scratch. Check storage layout, identify what must be preserved.
Pattern selection. UUPS for single contracts, Beacon for factories, Transparent for legacy projects with large team.
Upgrade testing. Hardhat test: deploy V1, record state, upgrade to V2, verify state preserved. Foundry: fork mainnet state via vm.createFork, run upgrade script on fork.
Deployment via multisig. All production deployments — via Safe Transaction Builder. No private keys in scripts.
Timeline
Implementing proxy pattern for new contract — 2-3 working days. Migrating existing non-proxy contract to upgradeable architecture (preserving data via migration script) — 3-7 days depending on storage complexity.
Cost calculated individually.
Checklist Before Deploying Upgradeable Contract
-
_disableInitializers()called in implementation constructor -
initialize()protected withinitializermodifier - Storage layout checked via OpenZeppelin Upgrades Plugin (
validate) - ProxyAdmin owner — multisig, not EOA
- Timelock configured for production
- New implementation verified on Etherscan before granting upgrade right
- Test: fork mainnet, upgrade, check storage







