Smart contracts are immutable by design. Upgradeable contracts are a workaround -- useful, but carrying real tradeoffs you need to understand.
The Core Problem
Once deployed, a contract's logic can't change. But bugs happen and requirements change. The upgrade pattern separates storage (proxy contract, permanent address) from logic (implementation contract, swappable).
User -> Proxy Contract (permanent address, holds storage)
|
| delegatecall
v
Implementation V1 (contains logic, uses proxy's storage)
After upgrade:
User -> Proxy Contract (same address)
|
| delegatecall
v
Implementation V2 (new logic, same storage)
delegatecall runs the implementation's code but in the proxy's context -- it reads and writes to the proxy's storage, not the implementation's.
UUPS Pattern (Recommended)
The upgrade logic lives in the implementation contract. More gas-efficient than Transparent proxy.
// Implementation V1
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract TokenV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
mapping(address => uint256) public balances;
uint256 public totalSupply;
// Use initialize() instead of constructor()
function initialize(address owner) public initializer {
__Ownable_init(owner);
__UUPSUpgradeable_init();
totalSupply = 1_000_000 * 10**18;
balances[owner] = totalSupply;
}
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
// Only owner can upgrade
function _authorizeUpgrade(address newImplementation)
internal override onlyOwner {}
}
// Implementation V2 -- adds a new feature
contract TokenV2 is TokenV1 {
mapping(address => bool) public frozen;
function freeze(address account) external onlyOwner {
frozen[account] = true;
}
function transfer(address to, uint256 amount) external override {
require(!frozen[msg.sender], "Account frozen");
super.transfer(to, amount); // reuse V1 logic
}
}
Deployment with Hardhat
import { ethers, upgrades } from 'hardhat'
// Deploy upgradeable proxy
const TokenV1 = await ethers.getContractFactory('TokenV1')
const token = await upgrades.deployProxy(TokenV1, [deployer.address], {
kind: 'uups'
})
await token.waitForDeployment()
console.log('Proxy address:', await token.getAddress())
console.log('Implementation:', await upgrades.erc1967.getImplementationAddress(await token.getAddress()))
// Upgrade to V2
const TokenV2 = await ethers.getContractFactory('TokenV2')
const upgraded = await upgrades.upgradeProxy(await token.getAddress(), TokenV2)
console.log('New implementation:', await upgrades.erc1967.getImplementationAddress(await upgraded.getAddress()))
Storage Layout: The Critical Gotcha
You cannot change the order or type of existing storage variables in an upgrade. New variables must be added at the END:
// V1 storage
contract TokenV1 {
mapping(address => uint256) public balances; // slot 0
uint256 public totalSupply; // slot 1
}
// V2 -- SAFE: only adds new variables after existing ones
contract TokenV2 is TokenV1 {
mapping(address => bool) public frozen; // slot 2 -- new slot
}
// V2 -- UNSAFE: changes existing storage layout
contract TokenBroken is TokenV1 {
uint256 public newVar; // slot 0 -- COLLISION with balances!
mapping(address => uint256) public balances; // slot 1 -- wrong slot!
}
Use OpenZeppelin's storage gap pattern for library contracts:
contract Base {
uint256 public value;
uint256[49] private __gap; // Reserve 49 slots for future use
}
When NOT to Use Upgradeable Contracts
Upgradeable contracts are a centralization risk. The owner can change any logic, including changing fund flows. For DeFi protocols where trustlessness matters, this is a red flag.
Prefer:
- Timelocks: 48-hour delay on upgrades so users can exit
- Governance: multi-sig or DAO vote required for upgrades
- Immutable contracts for core protocol logic, upgradeable for periphery
// Add a timelock delay
uint256 public constant UPGRADE_DELAY = 2 days;
mapping(address => uint256) public upgradeScheduled;
function scheduleUpgrade(address newImpl) external onlyOwner {
upgradeScheduled[newImpl] = block.timestamp + UPGRADE_DELAY;
}
function _authorizeUpgrade(address newImpl) internal override onlyOwner {
require(upgradeScheduled[newImpl] != 0, "Not scheduled");
require(block.timestamp >= upgradeScheduled[newImpl], "Too early");
delete upgradeScheduled[newImpl];
}
This gives users time to exit before an upgrade takes effect -- a meaningful security property for user funds.