Quizzr Logo

Smart Contracts

Designing Upgradeable Smart Contracts using the Proxy Pattern

Explore architectural strategies like UUPS and Transparent Proxies to maintain and update contract logic while preserving state on an immutable ledger.

BlockchainIntermediate12 min read

The Immutability Paradox and Architectural Flexibility

Smart contracts are fundamentally defined by their immutability, which ensures that once a program is deployed to a blockchain, its behavior is predictable and permanent. This security model is excellent for trust but presents a massive challenge when a critical security vulnerability is discovered or when business requirements evolve. In a traditional software stack, developers push a patch to a server, but on a decentralized ledger, the original code is locked forever at its specific address.

The primary goal of upgradeable architecture is to decouple the location of a contract from its specific business logic. By separating the entry point where users interact from the engine that processes data, developers can maintain a consistent interface while swapping out the underlying mechanics. This separation is essential for any decentralized application that intends to scale and survive in a rapidly changing technical landscape.

When we talk about upgrading a contract, we are not actually changing the existing code on the blockchain. Instead, we are changing the reference point that an entry contract uses to find its instructions. This mental model shifts our focus from editing files to managing pointers and storage layouts, ensuring that user balances and historical data remain intact across multiple iterations of the software.

Separating State from Logic

Every smart contract consists of two main parts which are the execution logic and the persistent storage state. In a non-upgradeable contract, these two components are fused together in a single deployment. To allow for updates, we must split these components into two distinct contracts where one serves as the vault for data and the other serves as the brain for calculations.

This architectural pattern uses a proxy contract to act as the primary face of the application. The proxy holds all the state variables, such as user balances and configuration settings, but it does not contain any business logic itself. When a user calls a function, the proxy essentially asks a logic contract how to handle the request, executes that instruction, and then records the result in its own local storage.

Real-World Scenario: The Evolving Vault

Consider a decentralized financial vault that allows users to deposit assets and earn interest through various yield strategies. Initially, the vault might only support simple lending protocols, but as the market matures, the developers may want to integrate more complex automated market makers. If the vault address changes every time an update is released, the user experience becomes fragmented and the liquidity is diluted.

By using an upgradeable proxy, the vault address remains the same for every user, exchange, and external integration. The developers can deploy a new implementation contract that includes the more advanced yield strategies and update the proxy to point to this new code. This transition happens seamlessly for the end-user, who continues to interact with the same contract address they have always trusted.

The Mechanics of Redirection with Delegatecall

The technical foundation of any upgradeable contract is the delegatecall opcode provided by the Ethereum Virtual Machine. This specialized instruction allows one contract to execute code from another contract while maintaining the original context of the caller. Crucially, the storage, balance, and sender identity of the calling contract are preserved during the execution of the remote code.

When a proxy contract uses delegatecall, it is telling the blockchain to run the code from the implementation contract but to treat the proxy as the owner of all the data. Any changes made to variables like a user balance are written to the storage slots of the proxy, not the implementation contract. This enables the implementation contract to be entirely stateless and easily replaceable without losing any data.

solidityBasic Proxy Fallback Logic
1// This function is triggered whenever the proxy is called
2fallback() external payable {
3    address _impl = implementationAddress;
4    assembly {
5        // Copy the incoming call data
6        calldatacopy(0, 0, calldatasize())
7        // Execute the call to the implementation contract
8        let result := delegatecall(gas(), _impl, 0, calldatasize(), 0, 0)
9        // Copy the return data back to the caller
10        returndatacopy(0, 0, returndatasize())
11        // Check if the call was successful
12        switch result
13        case 0 { revert(0, returndatasize()) }
14        default { return(0, returndatasize()) }
15    }
16}

A major risk in this design is the potential for storage collisions, which occur when the proxy and the logic contract expect variables to be in different memory locations. Because the proxy is managing the memory, the implementation contract must have an identical storage layout to avoid overwriting critical system flags or administrative pointers. Most developers solve this by following strict storage standards or using reserved slots defined by the community.

Understanding EIP-1967 Storage Slots

To prevent the proxy's internal variables from interfering with the logic contract's business data, the industry adopted the EIP-1967 standard. This standard reserves specific, randomized storage slots at the very end of the contract's memory space to store the address of the implementation logic. By placing these system-level pointers in extremely high-index slots, the chance of a collision with a developer-defined variable is virtually zero.

Following this standard also allows block explorers and developer tools to automatically identify that a contract is a proxy. This transparency is vital for users, as it allows them to see exactly which logic contract is currently controlling the proxy. Without a standardized location for the implementation address, the internal workings of the proxy would remain opaque and difficult to audit.

The Transparent Proxy Pattern

The Transparent Proxy Pattern was developed to solve a specific problem called the function selector clash. This occurs when a logic contract has a function with the same four-byte identifier as an administrative function in the proxy contract. If a clash occurs, the proxy might accidentally execute an upgrade command when a user simply intended to call a standard business function.

To resolve this, the Transparent Proxy acts differently depending on who is calling it. If the caller is the designated administrator, the proxy only exposes administrative functions like upgrading to a new logic address. If the caller is anyone else, the proxy ignores the administrative functions and delegates all calls to the implementation logic, ensuring no overlap can occur.

  • Security: Eliminates the risk of function selector clashes by separating administrative and user access.
  • Complexity: Requires the deployment of an additional ProxyAdmin contract to manage the upgrade process.
  • Gas Cost: Every call to the proxy requires an additional check of the msg.sender, which slightly increases transaction fees for users.

While this pattern is robust and has been the industry standard for years, it does come with a gas overhead. Every time a user interacts with the contract, the proxy must load the admin address from storage and compare it to the caller. For high-frequency applications, these small gas costs can add up, leading developers to look for more optimized alternatives.

The Role of the ProxyAdmin Contract

In a Transparent Proxy setup, the ProxyAdmin contract serves as the central authority for managing upgrades across multiple proxies. It acts as a middleman that ensures only authorized entities, such as a multi-signature wallet or a DAO governance module, can trigger an implementation change. This centralization of administrative power makes it easier to manage complex systems with dozens of different smart contracts.

The ProxyAdmin also prevents the proxy owner from accidentally calling logic functions that could change the state of the contract in unintended ways. By forcing the owner to go through the admin contract, the system maintains a clear audit trail of all architectural changes. This structural guardrail is one of the reasons why the Transparent Proxy is often recommended for enterprise-grade applications.

Universal Upgradeable Proxy Standard (UUPS)

The UUPS pattern is an evolution of the proxy architecture that moves the upgrade logic from the proxy contract into the implementation contract itself. This shift simplifies the proxy contract significantly, reducing it to a lean wrapper that only contains the delegation code. Because the proxy is smaller, it costs much less gas to deploy and slightly less gas to execute user transactions.

In a UUPS setup, the logic contract must inherit a specific interface that includes a function for upgrading the implementation. This means that every version of your software must explicitly include the code required to perform an upgrade. This design gives the developer more control over how upgrades are handled, but it also introduces a new set of risks regarding the lifecycle of the contract.

If you deploy a UUPS logic contract that lacks an upgrade function, or if the upgrade function is broken, the proxy becomes permanently immutable and can never be updated again.

This architectural style is often preferred for modern decentralized applications because of its gas efficiency. By removing the identity check required in Transparent Proxies, UUPS allows the EVM to jump straight to the logic execution. However, the developer must be extremely diligent in their testing to ensure that the upgrade path is never accidentally removed from the system.

The Upgrade Logic Migration

Because the upgrade function is part of the logic contract in UUPS, you can technically change the upgrade rules during any update. For example, you could start with a simple owner-controlled upgrade mechanism and later transition to a fully decentralized governance vote. This flexibility allows a project to gradually decentralize its management as the community grows and the software matures.

However, this flexibility requires careful management of the upgrade function's access control. If the upgrade function in the logic contract is not properly protected, any user could potentially redirect the proxy to a malicious contract. Developers must use robust modifiers and standardized libraries to ensure that only the authorized controller can call the migration function.

Implementation Comparison

solidityTypical UUPS Logic Contract
1contract VaultV2 is UUPSUpgradeable, Ownable {
2    uint256 public totalDeposits;
3
4    // Mandatory function for UUPS to authorize an upgrade
5    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
6
7    function deposit() external payable {
8        totalDeposits += msg.value;
9    }
10}

Notice how the logic contract itself defines who is allowed to perform the upgrade through the internal authorizeUpgrade function. This centralization of the logic allows the proxy to remain as lightweight as possible while still maintaining high security. The use of the Ownable pattern here ensures that only the current owner of the contract can initiate the transition to a third version of the code.

Strategic Implementation and Safety Checks

Implementing an upgradeable contract requires a shift in how we handle constructors and variable initialization. In the EVM, a constructor is only executed during the deployment of the contract, which means a proxy cannot see the values initialized in the implementation contract's constructor. Instead, we use an initializer function that is manually called immediately after the proxy is deployed to set the starting state.

This initializer function must be protected so that it can only be called once, similar to how a constructor behaves. If the initializer is left open, an attacker could re-initialize the contract and take ownership of the administrative roles. OpenZeppelin provides a base contract called Initializable that handles this logic safely, preventing the function from being executed a second time.

Another critical safety measure is the use of storage gaps in base contracts. When building complex inheritance structures, adding a new variable to a base contract in the future can shift the storage slots of all child contracts, leading to massive data corruption. By including a large array of unused storage slots in your base contracts, you create a buffer that allows you to add variables later without shifting the memory layout of the rest of the system.

Deployment and Validation Checklist

Before deploying an upgradeable contract to a live network, you must validate the compatibility of the storage layout between the current version and the proposed update. Tools like the OpenZeppelin Upgrades plugin can automatically compare your old and new contracts to detect potential issues like variable reordering or type changes. Ignoring these warnings can lead to a state where balances are misread or administrative permissions are lost.

Always perform a dry run of the upgrade on a local fork or a testnet before executing it on the mainnet. This allows you to verify that the initializer functions work as expected and that the proxy's state remains consistent. A successful upgrade is not just about the new code working, but about ensuring that the transition from the old logic to the new logic does not create any gaps in security or data integrity.

We use cookies

Necessary cookies keep the site working. Analytics and ads help us improve and fund Quizzr. You can manage your preferences.