Appearance
L3-7: Proxy Contracts
1. Problem
Once a smart contract is deployed to the blockchain, its bytecode is permanently immutable. This is necessary for trustlessness -- users need assurance that the contract rules will not change. However, it also creates a serious practical challenge: security bugs cannot be fixed, features cannot be upgraded, and all non-trivial software requires iteration. How can a contract's logic be upgraded without breaking its address and storage state (balances, user data, authorizations, etc.)?
The Proxy Pattern elegantly solves this problem: it separates a contract into a Proxy contract and a Logic/Implementation contract. The Proxy holds all state and address, but forwards function calls to the Logic contract via delegatecall. When upgrading, only the Logic contract address needs to be replaced -- the Proxy's address and storage remain unchanged. However, this pattern introduces three main variants (UUPS, Transparent, Beacon), each with different trade-offs in gas efficiency, deployment cost, and storage safety.
2. Why
Upgradability is one of the most important architectural decisions in professional Solidity development. Nearly all DeFi protocols (Uniswap, Aave, MakerDAO) use some form of proxy pattern. ERC-1967 standardizes storage slot positions to prevent storage collisions between the proxy and logic contracts. Understanding proxy contracts is not only a technical requirement but also a deep exercise in understanding how the Solidity compiler allocates storage under the hood, how delegatecall changes execution context, and why constructors cannot be used in the proxy pattern.
Proxy upgrades also involve governance questions: who has the authority to upgrade the contract? DAO voting, timelocks, or a single admin? A faulty upgrade can have catastrophic consequences -- a recent Compound governance vulnerability was caused by incorrect reward distribution logic introduced after an upgrade. Therefore, understanding the proxy's security model and upgrade permission management is a prerequisite for deploying upgradeable contracts.
3. Solution
ProxyContracts.sol implements the UUPS (Universal Upgradeable Proxy Standard) proxy pattern, consisting of three key contracts:
ERC1967Proxy (Proxy Contract): This is the contract users interact with. The constructor writes the logic contract address to the ERC-1967-specified storage slots on-chain:
IMPLEMENTATION_SLOT = keccak256("eip1967.proxy.implementation") - 1ADMIN_SLOT = keccak256("eip1967.proxy.admin") - 1
The fallback() function uses inline assembly to delegatecall any call to the logic contract. calldatacopy copies calldata, delegatecall executes the logic code in the proxy's context, and returndatacopy + return return the result.
UUPSLogic_V1 (Logic Contract V1): Contains business logic (setValue(), getValue()) and the UUPS upgrade function (upgradeTo()). The key point is that upgradeTo() resides in the logic contract (not the proxy), writing directly to the proxy's storage via sstore(IMPLEMENTATION_SLOT, newImplementation). The initialize() function replaces the constructor -- because the proxy's constructor does not delegatecall to the logic contract, initialization must be done through a separate initialization call.
UUPSLogic_V2 (Logic Contract V2): Inherits from V1, appending new variables at the end of V1's storage layout (string public name). The key is storage layout compatibility -- new variables must be added by appending, not inserting. Inherits upgradeTo(), so the upgrade logic does not need to be reimplemented.
Comparison of the Three Proxy Patterns:
- UUPS: Upgrade logic resides in the logic contract, lowest gas cost (the proxy does not need to check whether
msg.senderis admin), but there is a risk of the upgrade function being accidentally removed - Transparent Proxy: Upgrade logic resides in the proxy contract, which checks whether
msg.senderis admin to decide whether to forward the call or perform an upgrade, incurring an additional admin check overhead on every call - Beacon Proxy: Multiple proxies share a single Beacon contract, which holds the logic contract address. Upgrading the Beacon upgrades all proxies at once, yielding the lowest deployment cost but introducing centralization risk
Reference source file: src/level3/ProxyContracts.sol
4. Pitfalls Encountered
- Storage Collision: When upgrading from V1 to V2, if V2 inserts new variables in the middle, existing variables will shift, causing incorrect data reads or overwrites of previous variables
- constructor vs initializer: Using a constructor in the logic contract to initialize state -- although Solidity compiles successfully, the constructor runs when deploying the logic contract and does not take effect in the proxy's context
initialize()can be called multiple times: Without protection (such as OpenZeppelin'sinitializermodifier), a malicious actor can callinitialize()multiple times to reset the contract's state- Upgrade function being overridden: In UUPS, if V2 does not correctly inherit or redefine
upgradeTo(), the upgrade function may be accidentally overridden, rendering the contract permanently non-upgradeable selfdestructand delegatecall: If the logic contract containsselfdestruct, executing it will destroy the proxy contract's code- Function selector collisions: Admin functions of the proxy contract and regular functions of the logic contract may share the same 4-byte selector, leading to unexpected behavior
5. Why the Pitfalls Occur
Storage collisions are the most impactful and subtle issue in proxy upgrades. The Solidity compiler allocates storage slots in the order variables are declared -- in V1, owner is at slot 0 and value is at slot 1. If V2 declares string public name; address public owner; uint256 public value;, name will be allocated at slot 0, overwriting owner's data. The correct approach is to append: address public owner; uint256 public value; string public name; (name gets slot 2).
More subtle is storage layout in inheritance chains: if V1 inherits from contract A (which uses slots 0-2), and V1 uses slots 3-4, then V2 must maintain the exact same inheritance order, and any variables from newly inherited contracts must only be appended after all existing variables. OpenZeppelin's @openzeppelin/upgrades plugin validates upgrade compatibility using compiler-generated storage layout JSON.
The reason initialize() lacks protection is that Solidity's constructor does not run in a delegatecall context. initialize() is called manually, and without an initializer modifier (which sets a flag to prevent re-invocation), anyone can reinitialize the contract. OpenZeppelin's Initializable contract handles this through an internal _initialized flag.
6. How to Resolve the Pitfalls
Storage Layout Safety: Strictly follow the appending principle. Use OpenZeppelin's storage gap pattern:
solidity
contract UUPSLogic_V1 {
// ... business variables ...
uint256[50] private __gap; // Reserve 50 slots for future upgrades
}
V2 subtracts the number of slots occupied by new variables from __gap, keeping the total slot count unchanged.
initialize Protection: Add a one-time initialization check in V1:
solidity
function initialize(address _owner) public {
if (owner != address(0)) revert AlreadyInitialized();
owner = _owner;
}
Use OpenZeppelin's initializer modifier for more robust protection (tracks initialization state via bit operations).
Function Selector Collisions: Transparent Proxy solves this by routing admin calls through a different code path. If msg.sender == admin, the call is handled directly in the proxy (not forwarded). This avoids selector collisions between admin functions (like upgradeTo) and logic contract functions.
Inline Assembly Safety: In ERC1967Proxy's fallback(), check return values and data size after delegatecall. returndatasize() ensures only valid return data is copied. For the receive() function that accepts ETH, provide independent handling rather than forwarding to the logic contract.
7. Technical Highlights
| Technique | Description |
|---|---|
| delegatecall | Executes logic contract code in the proxy's context; storage operations affect the proxy address |
| ERC-1967 | Standardized storage slots: IMPLEMENTATION_SLOT and ADMIN_SLOT |
| UUPS | Upgrade logic resides in the logic contract, lowest gas cost |
| Transparent Proxy | Upgrade logic resides in the proxy; admin checked on every call |
| Beacon Proxy | Multiple proxies share a Beacon; one-click upgrade of all proxies |
| Storage Layout Compatibility | New variables can only be appended, never inserted or deleted |
| initializer | Replaces constructor; must guard against re-invocation |
sstore vs variable assignment | Use assembly to write directly to ERC-1967 slots without occupying regular storage space |
| fallback() assembly | calldatacopy -> delegatecall -> returndatacopy -> return/revert |