Proxy patterns are one of the most conceptually elegant constructs in the EVM ecosystem. A single address, permanent on-chain. The logic behind it, swappable. Delegates all execution to an implementation contract via DELEGATECALL, inheriting the implementation’s code but running it entirely in its own storage context.
That last sentence is where things go wrong.
When DELEGATECALL executes, storage reads and writes are performed against the caller’s storage — the proxy — using the callee’s (implementation’s) slot numbering system. If the proxy and the implementation make conflicting assumptions about what lives at any given slot, you get a collision. And collisions in production contracts holding millions of dollars tend to be expensive discoveries.
This article covers the complete attack surface of the proxy pattern: storage collisions, EIP-1967 slot derivation, drift across upgrade versions, function selector clashes, unprotected upgrade functions, the selfdestruct nuclear option, and a concrete verification workflow using cast and the Hardhat upgrades plugin. Every vulnerability comes with both a broken example and a corrected one.
1. Storage Layout Collisions
The Root Cause
Solidity maps variables to storage based on the order in which they were declared, after the contract inheritance chain is linearized: the first variable is assigned the first slot, and so on.
A naive proxy stores its own state variables — most commonly the implementation address and admin address — starting at slot 0. When that proxy delegates a call to an implementation contract, the implementation’s own slot-0 variable maps directly onto the proxy’s slot 0. They trample each other.
The proxy contract saves values from the logic implementation state variables in the relative position they were declared. If the proxy contract declares its own state variables, the storage collision will happen when both the proxy and logic contract attempt to use the same storage slot.
Vulnerable Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @dev VULNERABLE: naive proxy with slot-0 collision
contract NaiveProxy {
// Slot 0 — admin address
address public admin;
// Slot 1 — implementation address
address public implementation;
constructor(address _impl) {
admin = msg.sender;
implementation = _impl;
}
fallback() external payable {
address impl = implementation;
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()) }
}
}
}
/// @dev When this implementation writes to `owner` (slot 0),
/// it silently overwrites the proxy's `admin` variable.
contract VulnerableImplementation {
address public owner; // Slot 0 — collides with proxy.admin!
uint256 public value; // Slot 1 — collides with proxy.implementation!
function initialize(address _owner) external {
owner = _owner; // OVERWRITES proxy admin
}
function setValue(uint256 _value) external {
value = _value; // OVERWRITES implementation pointer!
}
}
Calling setValue(42) on this proxy writes 42 to slot 1, overwriting the implementation address stored there. Every subsequent call delegatecalls into address 0x000000000000000000000000000000000000002a — which is either empty or a completely different contract. The proxy is now broken beyond recovery.
2. EIP-1967 Slot Derivation
EIP-1967 was designed specifically to solve the naive slot collision. A consistent location where proxies store the address of the logic contract they delegate to, as well as other proxy-specific information. To avoid clashes in storage usage between the proxy and logic contract, the address of the logic contract is typically saved in a specific storage slot guaranteed to be never allocated by a compiler.
The slot derivation formula is precise:
Storage slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc (obtained as bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)).
The -1 subtraction is not aesthetic. They are chosen in such a way so they are guaranteed to not clash with state variables allocated by the compiler, since they depend on the hash of a string that does not start with a storage index. Furthermore, a -1 offset is added so the preimage of the hash cannot be known, further reducing the chances of a possible attack.
In other words: you cannot reverse-engineer a Solidity variable declaration whose compiler-assigned slot matches the EIP-1967 implementation slot, because the slot itself has no known keccak256 preimage.
Storage slot 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 (obtained as bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)) holds the address of the beacon.
Correct Pattern: EIP-1967 Compliant Proxy
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @dev EIP-1967 compliant proxy — no slot collisions
contract ERC1967Proxy {
// EIP-1967 implementation slot:
// bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
bytes32 private constant _IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
// EIP-1967 admin slot:
// bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)
bytes32 private constant _ADMIN_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
constructor(address _logic, bytes memory _data) payable {
_setImplementation(_logic);
_setAdmin(msg.sender);
if (_data.length > 0) {
(bool ok,) = _logic.delegatecall(_data);
require(ok, "ERC1967Proxy: init failed");
}
}
function _setImplementation(address newImpl) private {
require(newImpl.code.length > 0, "ERC1967Proxy: not a contract");
assembly {
sstore(_IMPLEMENTATION_SLOT, newImpl)
}
}
function _setAdmin(address newAdmin) private {
assembly {
sstore(_ADMIN_SLOT, newAdmin)
}
}
function _getImplementation() internal view returns (address impl) {
assembly {
impl := sload(_IMPLEMENTATION_SLOT)
}
}
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()) }
}
}
receive() external payable {}
}
By subtracting 1 from the result, we produce a random number which has no known hash preimage, so there is no way a contract can plug something into keccak256 to derive a storage slot that clashes with them.
3. Storage Drift Between Versions
Even with EIP-1967 protecting the proxy’s own slots, a subtle and far more insidious class of bugs waits in the implementation contract itself: storage drift across upgrade versions.
The proxy doesn’t track the semantic meaning of each storage slot. It’s a dumb forwarder. When you upgrade from ProtocolV1 to ProtocolV2, the new implementation inherits the proxy’s existing storage verbatim. If V2 declares variables in a different order, or removes a variable and shifts everything down, values in storage are now read through the wrong variable names.
Vulnerable Upgrade: Storage Drift
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ============================================================
// Version 1 — original layout
// Slot 0: owner
// Slot 1: totalSupply
// Slot 2: paused
// ============================================================
contract ProtocolV1 {
address public owner; // slot 0
uint256 public totalSupply; // slot 1
bool public paused; // slot 2
function initialize(address _owner) external {
require(owner == address(0), "already initialized");
owner = _owner;
totalSupply = 1_000_000e18;
}
}
// ============================================================
// Version 2 — BROKEN: a new variable inserted at the top,
// shifting every subsequent slot by one.
// Slot 0: version <-- NEW, tramples 'owner'
// Slot 1: owner <-- was slot 0, now reads totalSupply!
// Slot 2: totalSupply <-- was slot 1, now reads paused!
// Slot 3: paused <-- was slot 2, now reads garbage
// ============================================================
contract ProtocolV2_BROKEN {
uint256 public version; // slot 0 — INSERTED AT TOP
address public owner; // slot 1 — WRONG: reads old totalSupply
uint256 public totalSupply; // slot 2 — WRONG: reads old paused (bool)
bool public paused; // slot 3 — uninitialized
function initialize(address _owner) external {
require(owner == address(0), "already initialized");
owner = _owner;
}
function getVersion() external pure returns (uint256) {
return 2;
}
}
After upgrading to ProtocolV2_BROKEN, reading owner returns whatever raw bytes are at slot 1 — which contain totalSupply from V1. The contract is now functionally corrupted. If an access-controlled function checks owner == msg.sender, it will likely always revert because owner is now a gigantic number masquerading as an address.
Correct Pattern: Append-Only Layout
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ============================================================
// Version 2 — CORRECT: new variables only appended at the end
// Slot 0: owner (preserved)
// Slot 1: totalSupply (preserved)
// Slot 2: paused (preserved)
// Slot 3: version (NEW — appended safely)
// ============================================================
contract ProtocolV2_Safe {
address public owner; // slot 0 — unchanged
uint256 public totalSupply; // slot 1 — unchanged
bool public paused; // slot 2 — unchanged
uint256 public version; // slot 3 — NEW, appended at end
function getVersion() external pure returns (uint256) {
return 2;
}
}
The rule is absolute: never insert or remove variables in an existing implementation’s layout. Only append.
4. Storage Gaps
When using inheritance, slot assignment traverses the entire linearized inheritance chain. A base contract declaring variables occupies slots, and those slots must be preserved across upgrades. The standard defensive technique is the storage gap: a reserved array of slots at the bottom of each base contract that future versions can consume.
Storage collisions are the most dangerous pitfall; always use storage gap patterns and append-only state variables.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @dev Base contract with a storage gap
/// Reserves slots 1–49 for future variables without shifting children
abstract contract BaseUpgradeable {
address public owner; // slot 0
// Reserve 49 slots for future base contract variables.
// When a new variable is added to BaseUpgradeable, shrink this gap:
// uint256[48] private __gap; // if one new var added
uint256[49] private __gap;
function __Base_init(address _owner) internal {
owner = _owner;
}
}
/// @dev Child contract begins at slot 50 (after gap)
contract ProtocolV1 is BaseUpgradeable {
uint256 public totalSupply; // slot 50
function initialize(address _owner, uint256 _supply) external {
__Base_init(_owner);
totalSupply = _supply;
}
}
/// @dev Safe upgrade: base gains a new field by shrinking the gap,
/// child layout unaffected.
abstract contract BaseUpgradeable_V2 {
address public owner; // slot 0
bool public paused; // slot 1 — new, consumed from gap
uint256[48] private __gap; // was 49, now 48
}
This pattern ensures that adding state to a base contract never silently shifts the storage of any derived contract.
5. Function Selector Clashes
The Fundamental Problem
The Solidity compiler can detect selector clashes when the clash is produced by function signatures within the same contract, but not when such a clash happens across different contracts. For example, if a clash happened between a proxy contract and logic contract, the compiler would not be able to detect it, but inside the same proxy contract, the compiler would detect the clash.
Function selectors are only 4 bytes. Because the function selectors use a fixed amount of bytes, there will always be the possibility of a clash. This isn’t an issue for day-to-day development, given that the Solidity compiler will detect a selector clash within a contract, but this becomes exploitable when selectors are used for cross-contract interaction. Clashes can be abused to create a seemingly well-behaved contract that’s actually concealing a backdoor.
Transparent Proxy: The Caller-Based Resolution
The Transparent Upgradeable Proxy Pattern is a design pattern to completely eliminate the possibility of function selector clashing. Specifically, the Transparent Upgradeable Proxy Pattern dictates that there should be no public functions on the proxy except the fallback.
The resolution mechanism is caller identity: If any account other than the admin calls the proxy, the call will be forwarded to the implementation, even if that call matches one of the admin functions exposed by the proxy itself. If the admin calls the proxy, it can access the admin functions, but its calls will never be forwarded to the implementation.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @dev Illustrates the transparent proxy's caller-gated dispatch.
/// In practice, use OpenZeppelin's TransparentUpgradeableProxy.
contract TransparentProxyExample {
bytes32 private constant _IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 private constant _ADMIN_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
modifier onlyAdmin() {
require(msg.sender == _getAdmin(), "not admin");
_;
}
function _getAdmin() private view returns (address adm) {
assembly { adm := sload(_ADMIN_SLOT) }
}
function _getImplementation() private view returns (address impl) {
assembly { impl := sload(_IMPLEMENTATION_SLOT) }
}
/// @dev Admin-only: upgrade the implementation
function upgradeTo(address newImpl) external onlyAdmin {
assembly { sstore(_IMPLEMENTATION_SLOT, newImpl) }
}
fallback() external payable {
// KEY INVARIANT: admins never reach the implementation
require(msg.sender != _getAdmin(), "admin cannot call implementation");
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()) }
}
}
receive() external payable {}
}
This design works but carries a gas cost: every call not only incurs runtime gas cost of delegatecall from the proxy but also incurs cost of SLOAD for checking whether the caller is admin.
UUPS: Selector Clash Risk via Shared Function Namespace
UUPS moves upgrade logic into the implementation, which eliminates the admin check on every call. But it introduces a different selector risk: function selector clashes and metamorphic behavior occur because the proxy and implementation share the same function namespace. A poorly designed implementation could inadvertently have a function that shares a selector with one in the proxy’s interface (like upgradeTo), causing conflicts.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @dev Dangerous: implementation declares a function whose selector
/// collides with the proxy's upgradeTo(address) = 0x3659cfe6
contract DangerousImplementation {
address public owner;
// collideWithUpgrade() happens to hash to 0x3659cfe6 —
// same selector as upgradeTo(address).
// Any call to upgradeTo on the proxy would invoke THIS function instead.
function collideWithUpgrade() external {
// arbitrary logic, not an upgrade
owner = msg.sender;
}
}
The correct defense: always run a selector collision scan across your full proxy+implementation surface before deployment. Tools like cast sig make this trivial:
# Compute the selector for any function signature
cast sig "upgradeTo(address)"
# 0x3659cfe6
cast sig "collideWithUpgrade()"
# If these match, you have a collision
6. Unprotected _authorizeUpgrade in UUPS
Typically, the implementation inherits UUPSUpgradeable and the developer overrides _authorizeUpgrade to allow only the owner (or a multisig/DAO) to call it. If this step is neglected, an attacker could directly call upgradeTo on the proxy (which gets delegated to the implementation) and thus replace the implementation with malicious code.
Because UUPSUpgradeable is an abstract contract, the code will not compile unless you explicitly implement _authorizeUpgrade. But “compile” does not mean “correct.” An empty or insufficiently protected override is worse than no override at all.
Vulnerable Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
/// @dev VULNERABLE: _authorizeUpgrade has no access control.
/// Anyone can upgrade this proxy to any address.
contract VulnerableUUPS is Initializable, UUPSUpgradeable {
address public owner;
uint256 public value;
function initialize(address _owner) external initializer {
__UUPSUpgradeable_init();
owner = _owner;
}
// MISSING: access control modifier
// Any caller can trigger an upgrade!
function _authorizeUpgrade(address newImplementation)
internal
override
{}
function setValue(uint256 _value) external {
require(msg.sender == owner, "not owner");
value = _value;
}
}
Correct Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
/// @dev CORRECT: upgrade gated behind onlyOwner
contract SafeUUPS is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address _owner) external initializer {
__Ownable_init(_owner);
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner // <- access control enforced
{}
function setValue(uint256 _value) external onlyOwner {
value = _value;
}
}
By default, the upgrade functionality included in UUPSUpgradeable contains a security mechanism that will prevent any upgrades to a non-UUPS compliant implementation. This prevents upgrades to an implementation contract that wouldn’t contain the necessary upgrade mechanism, as it would lock the upgradeability of the proxy forever.
There is a second, equally dangerous failure mode: forgetting to protect _authorizeUpgrade in an upgrade. If you deploy a new implementation that accidentally removes or breaks _authorizeUpgrade, you permanently lose the ability to upgrade. This has happened in production.
7. The selfdestruct Risk in Implementations
This is the most catastrophic proxy vulnerability class. It combines two facts:
DELEGATECALLexecutes code in the context of the caller. When the proxy delegates to an implementation that triggersselfdestruct, it is the proxy that gets destroyed.- An uninitialized implementation is an open invitation.
The Wormhole Incident
The Wormhole bridge on Ethereum demonstrated this exact vector in production. The Wormhole Ethereum bridge had a UUPS-style proxy. After a routine update, one of the core contracts ended up uninitialized — a bug in their upgrade script had effectively “reset” the initialization. This opened a critical hole: the attacker (white-hat) was able to call the initialize() function on the implementation contract, making themselves the guardian (admin) of the bridge contract. With that authority, they then called the upgrade function to point the proxy to a malicious implementation under their control. The malicious implementation’s code, when invoked, simply executed a selfdestruct.
The next step was calling Wormhole’s routine upgrade entry point (submitContractUpgrade) which delegatecalled into the malicious implementation, triggering SELFDESTRUCT in the proxy’s own execution context. That opcode therefore deleted the proxy contract itself, instantly bricking the bridge; the implementation contract remained on-chain but was no longer reachable.
A bug bounty of $10 million was awarded by Wormhole to a whitehat through the platform Immunefi for this bug.
The Attack Chain
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ============================================================
// Step 1: Attacker deploys a malicious implementation
// ============================================================
contract MaliciousImpl {
// Matches the storage layout of the target to avoid immediate reverts
address public owner;
function initialize(address _owner) external {
owner = _owner;
}
// Step 3: Attacker calls this through the proxy via delegatecall.
// selfdestruct runs in the PROXY's context — destroying the proxy.
function destroy() external {
selfdestruct(payable(msg.sender));
}
}
// ============================================================
// Step 2: Vulnerable uninitialized UUPS implementation
// ============================================================
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract UninitializedImpl is Initializable, UUPSUpgradeable {
address public owner; // slot 0 — uninitialized = address(0)
// No constructor call to _disableInitializers().
// Anyone can call this on the bare implementation contract.
function initialize(address _owner) external initializer {
__UUPSUpgradeable_init();
owner = _owner;
}
function _authorizeUpgrade(address) internal override {
require(msg.sender == owner, "not owner");
// owner is address(0) if uninitialized — attacker can set themselves
}
}
// Attack sequence:
// 1. Attacker calls UninitializedImpl.initialize(attacker) directly
// on the bare implementation (not through the proxy)
// 2. Attacker calls UninitializedImpl.upgradeTo(MaliciousImpl)
// which runs in proxy context via delegatecall — updates proxy's impl slot
// 3. Attacker calls proxy.destroy() — delegatecalled into MaliciousImpl,
// selfdestruct runs in proxy context. Proxy is gone.
Correct Pattern: Disable Initializers
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract SecureImpl is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
// Permanently disables all initializers on the bare implementation.
// Calling initialize() directly on this contract will always revert.
// The proxy's own storage is unaffected — this only blocks direct calls.
_disableInitializers();
}
function initialize(address _owner) external initializer {
__Ownable_init(_owner);
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address) internal override onlyOwner {}
function setValue(uint256 _v) external onlyOwner {
value = _v;
}
}
Always initialize your proxy (or implementation, in UUPS) as part of deployment, and call _disableInitializers() on the logic contract.
The interaction between delegatecall and selfdestruct is unambiguous: if the proxy utilizes delegatecall, once selfdestruct is called, the destruction operation will be performed on the vulnerable contract’s storage (as this is how delegatecall works).
For UUPS proxies specifically, the risk is structurally worse: with a UUPS proxy, the logic to upgrade the contract is inside the implementation contract and the proxy cannot be upgraded if the implementation contract is destroyed.
8. Practical Verification: cast + Hardhat Upgrades Plugin
Knowing the vulnerabilities is necessary but not sufficient. You need a repeatable, scriptable verification workflow that runs before every upgrade is committed on-chain.
Step 1: Inspect the Proxy’s Implementation Slot with cast
Verify what implementation a live proxy is currently pointing at by reading the EIP-1967 slot directly:
# Read the EIP-1967 implementation slot from a live proxy
PROXY_ADDR="0xYourProxyAddress"
cast storage $PROXY_ADDR \
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \
--rpc-url $RPC_URL
# Read the EIP-1967 admin slot
cast storage $PROXY_ADDR \
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 \
--rpc-url $RPC_URL
# Alternatively, use cast's built-in proxy resolution
cast implementation $PROXY_ADDR --rpc-url $RPC_URL
Confirm the returned address matches your deployed implementation. Any discrepancy indicates either a botched upgrade or an unauthorized upgrade.
Step 2: Check Initialization Status on the Implementation
Before confirming a live deployment is safe, verify that the bare implementation contract is locked:
# If owner() returns 0x000...000, the implementation is uninitialized
cast call $IMPL_ADDR "owner()(address)" --rpc-url $RPC_URL
# Check the _initialized flag (OpenZeppelin Initializable stores it at slot 0)
cast storage $IMPL_ADDR 0 --rpc-url $RPC_URL
# If slot 0 returns 0x00, the implementation has NOT been initialized
# and _disableInitializers() was not called in the constructor
Step 3: Validate Storage Layout Compatibility with Hardhat Upgrades
validateUpgrade(proxyOrBeaconAddress, Contract) or validateUpgrade(Contract, Contract) validates the implementation for upgrade safety and compares its storage layout with the old version of the implementation for storage conflicts.
// scripts/validate-upgrade.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const PROXY_ADDRESS = process.env.PROXY_ADDRESS;
// Option A: validate against the live proxy (reads current impl from chain)
const ProtocolV2 = await ethers.getContractFactory("ProtocolV2");
await upgrades.validateUpgrade(PROXY_ADDRESS, ProtocolV2, {
kind: "uups",
});
// Option B: validate between two local contract factories (no network needed)
const ProtocolV1 = await ethers.getContractFactory("ProtocolV1");
await upgrades.
validateUpgrade("ProtocolV2");
}
Proxy and Upgradeability Audit Checklist
Storage layout
- Storage layout is append-only between versions — no existing slots are reused or reordered
- OpenZeppelin’s
@openzeppelin/upgrades-coreor equivalent is used to validate layout compatibility - Inherited contracts are not reordered between upgrades (changes the slot assignment of all subsequent variables)
- Mappings and dynamic arrays are not replaced by fixed-size arrays at the same slot
Initialization
-
_disableInitializers()is called in every implementation constructor - Deployment and initialization are atomic — no window where an uninitialized proxy is live
-
initializermodifier is used on all initialization functions and cannot be called twice - Admin, owner, and privileged role assignments happen inside
initialize, notconstructor
Access control on upgrade
-
upgradeTo/upgradeToAndCallis callable only by governance or a multisig with timelock - The upgrade authorization path is distinct from day-to-day admin operations
- No EOA can trigger an upgrade without a timelock and/or multisig threshold
Proxy pattern selection
- UUPS proxies have
_authorizeUpgradecorrectly overridden and access-controlled - Transparent proxies use the correct admin slot and do not allow admin to call implementation functions
- Beacon proxies share an upgrade path — a single beacon upgrade affects all proxies simultaneously, and this is documented as a risk
Deployment verification
- Storage layout diff between V1 and V2 is reviewed and signed off before upgrade
- The upgrade is tested on a fork of mainnet state before execution
- A rollback plan exists if the upgrade introduces a critical bug