Solidity Upgrades
Contents
Proxy Patterns Overview
| Pattern | Upgrade logic lives in | Best for |
|---|
UUPS (UUPSUpgradeable) | Implementation contract (override _authorizeUpgrade) | Most projects — lighter proxy, lower deploy gas |
| Transparent |
Separate
ProxyAdmin contract | When admin/user call separation is critical — admin cannot accidentally call implementation functions |
|
Beacon | Shared beacon contract | Multiple proxies sharing one implementation — upgrading the beacon atomically upgrades all proxies |
All three use EIP-1967 storage slots for the implementation address, admin, and beacon.
Transparent proxy — v5 constructor change: In v5, TransparentUpgradeableProxy automatically deploys its own ProxyAdmin contract and stores the admin address in an immutable variable (set at construction time, never changeable). The second constructor parameter is the owner address for that auto-deployed ProxyAdmin — do not pass an existing ProxyAdmin contract address here. Transfer of upgrade capability is handled exclusively through ProxyAdmin ownership. This differs from v4, where ProxyAdmin was deployed separately and its address was passed to the proxy constructor.
Upgrade Restrictions Between Major Versions (v4 → v5)
Upgrading a proxy's implementation from one using OpenZeppelin Contracts v4 to one using v5 is not supported.
v4 uses sequential storage (slots in declaration order); v5 uses namespaced storage (ERC-7201, structs at deterministic slots). A v5 implementation cannot safely read state written by a v4 implementation. Manual data migration is theoretically possible but often infeasible — mapping entries cannot be enumerated, so values written under arbitrary keys cannot be relocated.
Recommended approach: Deploy new proxies with v5 implementations and migrate users to the new address — do not upgrade proxies that currently point to v4 implementations.
Updating your codebase to v5 is encouraged. The restriction above applies only to already-deployed proxies. New deployments built on v5, and upgrades within the same major version, are fully supported.
Writing Upgradeable Contracts
Use initializers instead of constructors
Proxy contracts delegatecall into the implementation. Constructors run only when the implementation itself is deployed, not when a proxy is created. Replace constructors with initializer functions:
CODEBLOCK0
Key rules:
- - Top-level
initialize uses the initializer modifier - Parent init functions (
__X_init) use onlyInitializing internally — call them explicitly, the compiler does not auto-linearize initializers like constructors - Always call
_disableInitializers() in a constructor to prevent attackers from initializing the implementation directly - Do not set initial values in field declarations (e.g.,
uint256 x = 42) — these compile into the constructor and won't execute for the proxy. constant is safe (inlined at compile time). immutable values are stored in bytecode and shared across all proxies — the plugins flag them as unsafe by default; use /// @custom:oz-upgrades-unsafe-allow state-variable-immutable to opt in when a shared value is intended
Use the upgradeable package
Import from @openzeppelin/contracts-upgradeable for base contracts (e.g., ERC20Upgradeable, OwnableUpgradeable). Import interfaces and libraries from @openzeppelin/contracts. In v5.5+, Initializable and UUPSUpgradeable should also be imported directly from @openzeppelin/contracts — aliases in the upgradeable package will be removed in the next major release.
Complete import block example for v5.5+:
CODEBLOCK1
Storage layout rules
When upgrading, the new implementation must be storage-compatible with the old one:
- - Never reorder, remove, or change the type of existing state variables
- Never insert new variables before existing ones
- Only append new variables at the end
- Never change the inheritance order of base contracts
Namespaced storage (ERC-7201)
The modern approach — all @openzeppelin/contracts-upgradeable contracts (v5+) use this. State variables are grouped into a struct at a deterministic storage slot, isolating each contract's storage and eliminating the need for storage gaps. Recommended for all contracts that may be imported as base contracts.
CODEBLOCK2
Using a variable from namespaced storage:
CODEBLOCK3
Benefits over legacy storage gaps: safe to add variables to base contracts, inheritance order changes don't break layout, each contract's storage is fully isolated.
When upgrading, never remove a namespace by dropping it from the inheritance chain. The plugin flags deleted namespaces as an error — the state stored in that namespace becomes orphaned: the data remains on-chain but the new implementation has no way to read or write it. If a namespace is no longer actively used, keep the old contract in the inheritance chain. An unused namespace adds no runtime cost and causes no storage conflict. There is no targeted flag to suppress this error; the only bypass is unsafeSkipStorageCheck, which disables all storage layout compatibility checks and is a dangerous last resort.
Computing ERC-7201 storage locations
When generating namespaced storage code, always compute the actual STORAGE_LOCATION constant. Use the Bash tool to run the command below with the actual namespace id and embed the computed value directly in the generated code. Never leave placeholder values like 0x....
The formula is: keccak256(abi.encode(uint256(keccak256(id)) - 1)) & ~bytes32(uint256(0xff)) where id is the namespace string (e.g., "example.main").
Node.js with ethers:
CODEBLOCK4
Replace "example.main" with the actual namespace id, run the command, and use the output as the constant value.
Unsafe operations
- - No
selfdestruct — on pre-Dencun chains, destroys the implementation and bricks all proxies. Post-Dencun (EIP-6780), selfdestruct only destroys code if called in the same transaction as creation, but the plugins still flag it as unsafe - No
delegatecall to untrusted contracts — a malicious target could selfdestruct or corrupt storage
Additionally, avoid using new to create contracts inside an upgradeable contract — the created contract won't be upgradeable. Inject pre-deployed addresses instead.
Hardhat Upgrades Workflow
Install the plugin:
CODEBLOCK5
Register in hardhat.config:
CODEBLOCK6
Workflow concept — the plugin provides functions on the upgrades object (deployProxy, upgradeProxy, deployBeacon, upgradeBeacon, deployBeaconProxy). Each function:
- 1. Validates the implementation for upgrade safety (storage layout, initializer patterns, unsafe opcodes)
- Deploys the implementation (reuses if already deployed)
- Deploys or updates the proxy/beacon
- Calls the initializer (on deploy)
The plugin tracks deployed implementations in .openzeppelin/ per-network files. Commit non-development network files to version control.
Use prepareUpgrade to validate and deploy a new implementation without executing the upgrade — useful when a multisig or governance contract holds upgrade rights.
Read the installed plugin's README or source for exact API signatures and options, as these evolve across versions.
Foundry Upgrades Workflow
Install dependencies:
CODEBLOCK7
Configure foundry.toml:
CODEBLOCK8
Node.js is required — the library shells out to the OpenZeppelin Upgrades CLI for validation.
Import and use in scripts/tests:
CODEBLOCK9
Key differences from Hardhat:
- - Contracts are referenced by name string, not factory object
- No automatic implementation tracking — annotate new versions with
@custom:oz-upgrades-from or pass referenceContract in the Options struct - INLINECODE52 variant skips all validation (takes addresses instead of names) — never use in production scripts
- Run
forge clean or use --force before running scripts
Read the installed library's Upgrades.sol for the full API and Options struct.
Handling Upgrade Validation Issues
When the plugins flag a warning or error, work through this hierarchy:
- 1. Fix the root cause. Determine whether the code can be restructured to eliminate the concern entirely — remove the problematic pattern or refactor storage. This is always the right first step.
- Use in-code annotations if the situation is genuinely safe. If restructuring isn't appropriate and you've determined the flagged pattern is actually safe, the plugins support annotations that let you document that judgment directly at the source. Check the installed plugin's docs for what's available. These annotations create a clear audit trail of intentional exceptions — use them only after evaluating the safety, not as a shortcut.
- Use a narrow flag if an annotation won't work. Some cases (e.g., a third-party base contract you can't modify) can't be addressed in source. Use the most targeted flag available, scoped to the specific construct.
- Broad bypass as a last resort, with full awareness of the risk. Options like
UnsafeUpgrades (Foundry) or blanket unsafeAllow entries skip all validation for the affected scope. If you use them, comment why, and verify manually — the plugin is no longer protecting you.
Upgrade Safety Checklist
- - [ ] Storage compatibility: No reordering, removal, or type changes of existing variables. Only append new variables (or add fields to namespaced structs).
- [ ] Initializer protection: Top-level
initialize uses initializer modifier. Implementation constructor calls _disableInitializers(). - [ ] Parent initializers called: Every inherited upgradeable contract's
__X_init is called exactly once in initialize. - [ ] No unsafe opcodes: No
selfdestruct or delegatecall to untrusted targets. - [ ] Function selector clashes: Proxy admin functions and implementation functions must not share selectors. UUPS and Transparent patterns handle this by design; custom proxies need manual review.
- [ ] UUPS
_authorizeUpgrade: Overridden with proper access control (e.g., onlyOwner). Forgetting this makes the proxy non-upgradeable or upgradeable by anyone. - [ ] Test the upgrade path: Deploy V1, upgrade to V2, verify state is preserved and new logic works. Both Hardhat and Foundry plugins can validate upgrades in test suites.
- [ ] Reinitializer for V2+: If V2 needs new initialization logic, use
reinitializer(2) modifier (not initializer, which can only run once). The version number must be strictly incremented for each upgrade that requires re-initialization: V2 uses reinitializer(2), V3 uses reinitializer(3), and so on. Reusing the same version number causes silent skipping — the function executes without error but the body does not run, leaving new state variables at zero/default values (e.g., a new access control role is never set). Always verify in tests that reinitializer logic actually executed by checking the state it was supposed to set. - [ ] Unique ERC-7201 namespace ids: No two contracts in the inheritance chain share the same namespace id. Colliding ids map to the same storage slot, causing silent storage corruption.
Solidity 升级
目录
代理模式概述
| 模式 | 升级逻辑所在位置 | 最佳适用场景 |
|---|
| UUPS (UUPSUpgradeable) | 实现合约(重写 _authorizeUpgrade) | 大多数项目 — 代理更轻量,部署 gas 更低 |
| 透明代理 |
独立的 ProxyAdmin 合约 | 管理员/用户调用分离至关重要时 — 管理员无法意外调用实现函数 |
|
信标 | 共享的信标合约 | 多个代理共享同一实现 — 原子升级信标即可升级所有代理 |
三者均使用 EIP-1967 存储槽存储实现地址、管理员和信标。
透明代理 — v5 构造函数变更: 在 v5 中,TransparentUpgradeableProxy 自动部署自己的 ProxyAdmin 合约,并将管理员地址存储在不可变变量中(在构造时设置,不可更改)。第二个构造函数参数是该自动部署的 ProxyAdmin 的所有者地址 — 请不要在此处传入现有的 ProxyAdmin 合约地址。升级能力的转移完全通过 ProxyAdmin 所有权处理。这与 v4 不同,v4 中 ProxyAdmin 是单独部署的,其地址被传递给代理构造函数。
主版本升级限制(v4 → v5)
不支持将代理的实现从使用 OpenZeppelin Contracts v4 升级到使用 v5。
v4 使用顺序存储(按声明顺序的槽位);v5 使用命名空间存储(ERC-7201,在确定性槽位上的结构体)。v5 实现无法安全读取 v4 实现写入的状态。理论上可以进行手动数据迁移,但通常不可行 — mapping 条目无法枚举,因此在任意键下写入的值无法重新定位。
推荐方法: 使用 v5 实现部署新代理,并将用户迁移到新地址 — 不要升级当前指向 v4 实现的代理。
鼓励将代码库更新到 v5。 上述限制仅适用于已部署的代理。基于 v5 的新部署以及同一主版本内的升级完全支持。
编写可升级合约
使用初始化器替代构造函数
代理合约通过 delegatecall 调用实现。构造函数仅在实现本身部署时运行,而非创建代理时。用初始化函数替代构造函数:
solidity
import {Initializable} from @openzeppelin/contracts/proxy/utils/Initializable.sol;
contract MyToken is Initializable, ERC20Upgradeable, OwnableUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // 锁定实现
}
function initialize(address initialOwner) public initializer {
ERC20_init(MyToken, MTK);
Ownable_init(initialOwner);
}
}
关键规则:
- - 顶层 initialize 使用 initializer 修饰符
- 父级初始化函数(Xinit)内部使用 onlyInitializing — 显式调用它们,编译器不会像构造函数那样自动线性化初始化器
- 始终在构造函数中调用 disableInitializers(),防止攻击者直接初始化实现
- 不要在字段声明中设置初始值(例如 uint256 x = 42)— 这些会编译到构造函数中,不会为代理执行。constant 是安全的(编译时内联)。immutable 值存储在字节码中,在所有代理间共享 — 插件默认将其标记为不安全;当有意共享值时,使用 /// @custom:oz-upgrades-unsafe-allow state-variable-immutable 选择加入
使用可升级包
从 @openzeppelin/contracts-upgradeable 导入基础合约(例如 ERC20Upgradeable、OwnableUpgradeable)。从 @openzeppelin/contracts 导入接口和库。在 v5.5+ 中,Initializable 和 UUPSUpgradeable 也应直接从 @openzeppelin/contracts 导入 — 可升级包中的别名将在下一个主版本中移除。
v5.5+ 的完整导入块示例:
solidity
// 来自 @openzeppelin/contracts(非可升级包)
import {Initializable} from @openzeppelin/contracts/proxy/utils/Initializable.sol;
import {UUPSUpgradeable} from @openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol;
// 来自 @openzeppelin/contracts-upgradeable(可升级基础合约)
import {ERC20Upgradeable} from @openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol;
import {OwnableUpgradeable} from @openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol;
存储布局规则
升级时,新实现必须与旧实现存储兼容:
- - 切勿重新排序、删除或更改现有状态变量的类型
- 切勿在现有变量之前插入新变量
- 只能在末尾追加新变量
- 切勿更改基础合约的继承顺序
命名空间存储(ERC-7201)
现代方法 — 所有 @openzeppelin/contracts-upgradeable 合约(v5+)都使用此方法。状态变量被分组到确定性存储槽的结构体中,隔离每个合约的存储,无需存储间隙。推荐用于所有可能作为基础合约导入的合约。
solidity
/// @custom:storage-location erc7201:example.main
struct MainStorage {
uint256 value;
mapping(address => uint256) balances;
}
// keccak256(abi.encode(uint256(keccak256(example.main)) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant MAINSTORAGELOCATION = 0x...;
function _getMainStorage() private pure returns (MainStorage storage $) {
assembly { $.slot := MAINSTORAGELOCATION }
}
从命名空间存储中使用变量:
solidity
function _getBalance(address account) internal view returns (uint256) {
MainStorage storage $ = _getMainStorage();
return $.balances[account];
}
相比传统存储间隙的优势:安全地向基础合约添加变量,继承顺序更改不会破坏布局,每个合约的存储完全隔离。
升级时,切勿通过从继承链中删除命名空间来移除它。插件将删除的命名空间标记为错误 — 存储在该命名空间中的状态成为孤儿:数据仍在链上,但新实现无法读取或写入。如果命名空间不再活跃使用,请将旧合约保留在继承链中。未使用的命名空间不会增加运行时成本,也不会导致存储冲突。没有针对性的标志来抑制此错误;唯一的绕过方式是 unsafeSkipStorageCheck,它会禁用所有存储布局兼容性检查,是危险的最后手段。
计算 ERC-7201 存储位置
生成命名空间存储代码时,始终计算实际的 STORAGE_LOCATION 常量。使用 Bash 工具运行以下命令,传入实际的命名空间 ID,并将计算出的值直接嵌入生成的代码中。切勿保留像 0x... 这样的占位值。
公式为:keccak256(abi.encode(uint256(keccak256(id)) - 1)) & ~bytes32(uint256(0xff)),其中 id 是命名空间字符串(例如 example.main)。
Node.js 与 ethers:
bash
node -e const{keccak256,toUtf8Bytes,zeroPadValue,toBeHex}=require(ethers);const id=process.argv[1];const h=BigInt(keccak256(toUtf8Bytes(id)))-1n;console.log(toBeHex(BigInt(keccak256(zeroPadValue(toBeHex(h),32)))&~0xffn,32)) example.main
将 example.main 替换为实际的命名空间 ID,运行命令,并将输出用作常量值。
不安全操作
- - 无 selfdestruct — 在 Dencun 之前的链上,会销毁实现并使所有代理失效。Dencun 之后(EIP-6780),selfdestruct 仅在创建同一交易中调用时销毁代码,但插件仍将其标记为不安全
- 无对不可信合约的 delegatecall — 恶意目标可能 selfdestruct 或破坏存储
此外,避免在可升级合约内部