Writing Upgradable Smart Contracts
1) Why upgrades?
Bug fixes & security patches
Feature evolution without migrating state
Parameter tuning (fees, limits) via governance
2) Core idea: Proxy + Logic
Users interact with a proxy that holds state and delegatecalls to an implementation (logic) contract you can replace.
Popular patterns
Transparent Proxy (EIP-1967)
Separate ProxyAdmin account controls upgrades.
Calls from admin go to admin functions; all others are delegated.
Simple, battle-tested; default in OpenZeppelin.
UUPS (EIP-1822)
The implementation contract contains the upgrade function (upgradeTo).
Proxy is minimal; saves gas on deployments.
Requires an onlyProxy guard and proxiableUUID safety.
Beacon Proxy
Many proxies point to a Beacon that holds the implementation address.
Upgrading the beacon upgrades all proxies at once (good for factories).
Diamond (EIP-2535)
Modular “facets” with function selectors; advanced/composable.
Powerful but complex; only use if you really need modular hot-swapping.
3) Storage layout rules (super important)
Upgrades must not corrupt storage. Follow these rules:
Never change the order of existing state variables.
Do not remove or rename existing variables.
Only append new variables at the end.
Reserve space using uint256[50] private __gap; for future variables (OZ pattern).
Avoid immutable and constructor storage (use initializers instead).
Be careful when changing inheritance—it affects layout.
Don’t switch between 721/1155 mixins or libraries that alter storage slots without a plan.
4) Constructors vs Initializers
Proxies don’t run constructors of the implementation. Use initializer functions.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract ExampleV1 is Initializable {
uint256 public x;
address public owner;
uint256[48] private __gap; // keep room for future vars
function initialize(uint256 _x, address _owner) public initializer {
x = _x;
owner = _owner;
}
}
Use initializer for the first init; for later versions use reinitializer(version) (e.g., reinitializer(2)).
Disable initializers in the implementation’s constructor: _disableInitializers(); to prevent someone from initializing the logic contract directly.
5) UUPS upgradeable example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract CounterV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public count;
uint256[49] private __gap; // reserve
function initialize(uint256 _start) public initializer {
__Ownable_init(msg.sender);
__UUPSUpgradeable_init();
count = _start;
}
function inc() external { count += 1; }
// UUPS authorization
function _authorizeUpgrade(address newImpl) internal override onlyOwner {}
}
// V2 keeps SAME storage layout + appends only
contract CounterV2 is CounterV1 {
function dec() external { require(count > 0, "underflow"); count -= 1; }
}
6) Transparent proxy example (concept)
Deploy CounterV1.
Deploy TransparentUpgradeableProxy with:
implementation = CounterV1
admin = ProxyAdmin
data = encoded initialize(...)
To upgrade:
Deploy CounterV2.
ProxyAdmin.upgrade(proxy, implV2).
7) Access control & governance
Upgrades should be restricted by:
ProxyAdmin owner (EOA or multisig) for Transparent pattern.
_authorizeUpgrade (UUPS) – typically Ownable, RBAC, or DAO timelock.
Best practice: Multisig + Timelock (e.g., 24–48h) governed by a DAO. This lets users act if a malicious upgrade is queued.
Emit Upgrade events; publish diffs & audits for transparency.
8) What you can change safely
Add new functions.
Add new state variables at the end.
Fix logic bugs that don’t rely on re-ordered storage.
Introduce new modules via inheritance after existing storage layout.
What you must not do:
Reorder or delete variables.
Change types/sizes of existing variables.
Move a parent contract with storage ahead of another parent in inheritance linearization.
Switch to libraries that introduce new storage slots at the top.
9) Tooling (recommended)
OpenZeppelin Upgrades Plugins
Hardhat: @openzeppelin/hardhat-upgrades
Foundry: openzeppelin-foundry-upgrades
Truffle: @openzeppelin/truffle-upgrades
These:
Deploy proxies, run initializers.
Validate storage layout across versions.
Protect UUPS with proxiableUUID checks.
Provide upgradeProxy helpers.
Hardhat example
// deploy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const CounterV1 = await ethers.getContractFactory("CounterV1");
const proxy = await upgrades.deployProxy(CounterV1, [5], { kind: "uups" });
await proxy.waitForDeployment();
console.log("Proxy at:", await proxy.getAddress());
}
// upgrade.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const CounterV2 = await ethers.getContractFactory("CounterV2");
const proxyAddr = "0xYourProxy";
await upgrades.upgradeProxy(proxyAddr, CounterV2); // validates layout
}
10) Testing upgrades (must-do)
Unit tests for each version (V1, V2…).
Storage layout test: set state in V1 → upgrade → assert values unchanged in V2.
Event & behavior tests: ensure new functions work and old functions behave identically.
Fuzz/Property tests: check invariants across upgrade boundaries.
Simulate governance: queue → timelock → execute → upgrade.
11) Security checklist
Use ReentrancyGuard where needed; respect checks-effects-interactions.
Add Pausable (circuit breaker) in case of emergencies.
Disable initializers in implementation constructors.
Protect UUPS _authorizeUpgrade.
For Transparent proxies, keep ProxyAdmin on a multisig.
Consider upgrade beacons for mass upgrades; beware blast radius.
Avoid selfdestruct (deprecated and dangerous for upgrade assumptions).
Document & verify EIP-1967 slots if writing custom proxies.
Run static analysis (Slither), linters, and use audited libraries (OpenZeppelin).
12) Versioning & operations
Tag each implementation with semver (v1.2.0), publish ABI & commit hash.
Maintain a changelog with storage diffs and migration notes.
Roll out upgrades with canary deployments (one instance or small subset first, if your architecture allows).
Keep runbooks for rollback (upgrade back to previous impl).
13) Common pitfalls (and fixes)
Constructor logic lost → move to initialize/reinitializer.
Storage collision → use OZ plugins to validate; append only; keep __gap.
Calling upgrade functions on the implementation → block by _disableInitializers and access control.
Delegatecall context gotchas → address(this) is the proxy; msg.sender is the external caller.
Immutable variables → they won’t be changeable after upgrade; consider regular storage instead.
Library storage → ensure libs don’t introduce unexpected slots, or use library with pure/view logic.
14) When to choose which pattern?
Transparent Proxy: simplest ops, clear separation—great default for most apps.
UUPS: you want minimal proxies, lots of instances, and are comfortable enforcing _authorizeUpgrade.
Beacon: factory pattern with many identical clones that should upgrade together.
Diamond: complex, large codebase with feature modules and selective upgrades.
Quick upgrade playbook
Implement V1 with initializer, __gap, access control, and events.
Deploy via OZ Upgrades (Transparent or UUPS).
Write tests that persist state, then upgrade to V2 in tests.
Add new vars at the end; never reorder.
Gate upgrades with multisig + timelock, announce, and monitor.
If you want, I can:
convert this into a step-by-step checklist for your repo,
provide a ready-to-run Hardhat/Foundry template,
or adapt the examples for Transparent or Beacon patterns with full scripts.
Learn Blockchain Course in Hyderabad
Read More
Building an NFT Marketplace from Scratch
Flash Loan Arbitrage: Real-World Examples
Comments
Post a Comment