Skip to content

Upgradeability in Smart Contracts: Recommendations and Security

Navigating the realm of smart contract development presents inherent difficulties, primarily due to the immutability of the code. Once a smart contract is deployed, its code - and, unfortunately, any resident bugs - becomes fixed and unchangeable. To circumvent this challenge, developers frequently incorporate emergency upgrade functionality, enabling vital security fixes to be implemented.

However, the notion of upgradeability is not without its own set of complexities and challenges. These range from technical intricacies to potentially unforeseen problems arising from the upgrade process. This article explores these issues, providing an in-depth exploration of upgradeability in smart contracts. Its objective is to stimulate critical thinking among developers, promoting a mindful approach to creating upgradeable smart contracts.

Paradoxical Nature

Blockchain applications are founded on immutability, guaranteeing that data, once stored, cannot be retrospectively manipulated. This defining characteristic fosters trust and security among users. However, when brought into software development, where the inevitability of bugs and vulnerabilities necessitates prompt fixes, it presents an inherent contradiction. Given that blockchain applications are written in programming languages not exempt from security vulnerabilities, this creates a paradox.

In a mutable software environment, a developer can readily implement a fix or release a new version to address any discovered vulnerabilities. On-chain software, on the other hand, necessitates a more intricate approach. Upgradeability ensures that smart contracts are not obsoleted due to newly discovered bugs or updates.

However, upgradeability brings its own set of challenges and complications. It implies the existence of an authority with the power to decide what and when to update. This could be a centralized owner - which raises the risk of private critical loss - or a more complex construct like a multisig or DAO, significantly increasing the overall system complexity.

Furthermore, upgradeability is somewhat contradictory to the decentralization ethos of blockchain technology, as it allows implementations to change unexpectedly. For instance, if a user sends a transaction to an upgradeable system, and an upgrade occurs in the transaction preceding the user's, their transaction might be executed in a completely different context.

Since the proxy holds all the storage information and often user assets, malicious upgrades pose a significant threat. Thus, the paradox of upgradeability in blockchain technology presents a predicament for developers.

In conclusion, it may be advisable to avoid upgradeability altogether and instead opt for alternative techniques such as migrations or discard the notion of upgrades entirely. This approach demands more due diligence before release. Still, it ultimately results in trustless software and stronger trust guarantees for the community.

Centralization

Upgradeability, as previously discussed, implies an authority with the power to trigger an upgrade. This challenge is commonly addressed through a proxy pattern that includes an owner assigned to a specific address. This owner can take many forms, from an address associated with a private key to a more complex smart contract such as a multisig or a Decentralized Autonomous Organization (DAO).

However, the authority to perform upgrades carries an inherent risk of centralization. In this context, centralization refers to the concentration of control in a single point or party. A party with the power to upgrade effectively can introduce malicious code, censor specific transactions by sandwiching user transactions with upgrades, or even perform an outright rug pull, resulting in significant losses for users.

Even introducing a governance model does not guarantee protection from these risks, as such models can be susceptible to exploits and other issues. The 2023 Tornado Cash governance attack is a potent example of a malicious proposal exploit. Classic governance problems, such as low voter participation rates, also persist.

Several protective measures must be taken when upgradeability is essential for a system's functionality. The system must have an active community invested in its ongoing development and security. A robust and transparent governance mechanism is required to ensure all stakeholders can participate in decision-making processes. Any potentially malicious upgrade proposals must undergo a comprehensive security vetting process and be scrutinized by the system's developers and security professionals.

Transparent Proxies

Transparent proxies are a commonly used pattern that lets developers separate a smart contract's logic and data. This is achieved by providing a mechanism to upgrade the contract's logic at the implementation address while preserving the state of the contract, including balances and other data on the proxy address.

A transparent proxy contract forwards calls to a specific implementation contract containing the system's actual business logic. The transparent proxy uses delegate calls for this purpose. A delegate call executes the called contract's code in the context of the calling contract. This means that while the logic runs in the implementation contract, the acted-upon storage is that of the calling contract, i.e., the proxy contract.

This design allows the implementation contract to be replaced, effectively upgrading the contract logic without affecting the proxy contract's state. An account or another contract with the necessary authority can change the implementation contract address, usually referred to as the proxy "admin" or "owner."

Here's a simple representation of how a transparent proxy works:

sequenceDiagram
    User ->> Proxy: Sends transaction
    Note right of Proxy: Checks if the sender is admin
    Proxy-->>User: If the sender is admin, allows upgrade
    Proxy ->> Implementation Contract: Forwards call using delegatecall
    Implementation Contract ->>Proxy: Executes logic, returns data
    Proxy-->>User: Returns data

In this diagram, the user sends a transaction to the proxy. If the user is the admin, the proxy does not delegate the call and executes the admin-specified operation, e.g. an upgrade of the implementation contract. Otherwise, the proxy forwards the transaction transparently to the implementation contract using delegatecall. The implementation contract then executes its logic and returns the data to the proxy, which passes the result transparently to the user.

While proxies facilitate contract upgrades, they introduce additional complexity and potential security risks. Always carefully consider the need for upgradeability versus these associated risks and challenges.

Universal Upgradeable Proxy Standard (UUPS)

The Universal Upgrade Proxy Standard (UUPS) is another model for upgradable smart contracts proposed by EIP-1822. While it utilizes the same delegatecall function as a transparent proxy, the UUPS model adds a twist: managing upgrades is shifted from the proxy contract to the implementation contract.

In the UUPS model, the implementation contract usually inherits a contract such as OpenZeppelin's UUPSUpgradeable, providing upgrade functionality. The address of the implementation contract is stored in the proxy contract. When calls are made to the proxy, they are forwarded to the implementation contract via delegatecall. This allows the logic contract to operate on the storage of the proxy contract.

A benefit of the UUPS model is that it mitigates the risk of a function selector clash. This happens because the Solidity compiler can detect clashing functions within the same contract but not across two contracts. Moreover, UUPS proxies have a smaller storage footprint, making them cheaper to deploy than transparent proxies.

However, using UUPS proxies has a significant caveat. Once the proxy contract is upgraded to a logic contract that does not inherit the upgrade functionality, it will be impossible to upgrade the proxy. This aspect demands careful planning and meticulous checking when deploying upgrades.

sequenceDiagram
    User ->> Proxy: Sends transaction
    Proxy ->> Implementation Contract: Forwards call using delegatecall
    Note right of Implementation Contract: Contains upgrade functionality
    Implementation Contract->>Proxy: Executes logic, returns data
    Proxy-->>User: Returns data
    Admin ->> Implementation Contract: Request to upgrade
    Implementation Contract->>Proxy: Updates implementation contract address in Proxy

In this diagram, similar to the transparent proxy example, a user sends a transaction to the proxy, which is then forwarded to the implementation contract using delegatecall. The difference is when an admin requests an upgrade. The request is sent to the implementation contract, which in turn updates the address of the implementation contract stored in the proxy.

Beacon Proxies

The essential characteristic of a beacon proxy setup is that multiple proxies refer to a single contract, known as a "beacon" contract, to obtain the address of the implementation contract.

Beacon proxies become particularly useful when multiple proxies refer to the same implementation contract that is upgraded over time. In such scenarios, using Transparent or UUPS proxies would necessitate upgrading the address of the implementation contract in each proxy individually. With a Beacon proxy, you need only to upgrade the implementation contract address stored within the beacon contract.

In the context of a Beacon proxy upgrade, the call is made to the beacon contract, updating the stored implementation contract address. This upgrade functionality is typically reserved for the owner address, which by default is the address that deployed the beacon. Following an upgrade, all proxies referring to the beacon contract will immediately reference the new contract, simultaneously upgrading all associated proxies to the new implementation.

Unlike most proxy patterns where the implementation contract address is stored within the proxy contract's storage, the Beacon pattern, first popularized by Dharma in 2019, keeps the address of the implementation contract in a separate beacon contract. The address of the beacon is held within the proxy contract, conforming to the EIP-1967 storage pattern.

This design simplifies upgrades and enables powerful combinations when managing large quantities of proxy contracts that require grouping in different ways. The admin adjusts both the beacon address in the proxy and the implementation contract address in the beacon.

sequenceDiagram
User ->> Proxy: Sends transaction
Proxy ->> Beacon Contract: Retrieves current Implementation Contract Address
Beacon Contract-->>Proxy: Returns Implementation Contract Address
Proxy ->> Implementation Contract: Forwards call using delegatecall
Implementation Contract->>Proxy: Executes logic, returns data
Proxy-->>User: Returns data
Admin ->> Beacon Contract: Request to upgrade
Beacon Contract->>Beacon Contract: Updates Implementation Contract Address

In this sequence diagram, a user sends a transaction to the proxy, retrieving the current implementation contract address from the beacon contract. The call is then forwarded to the implementation contract using a delegatecall. When an admin requests an upgrade, the request is sent to the beacon contract, which updates the address of the implementation contract. This update is instantly reflected across all associated proxies.

Struct Storage Collisions

When developing smart contracts in Solidity, upgrades can pose unique challenges, particularly concerning struct growth and storage collisions. The extension of structs, often overlooked, represents a significant concern for contract upgrades.

Consider the following structs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct Foo {
    address a;
    uint96 b;
    uint256 c;
    uint256 d;
    uint256 e;
}

struct Bar {
    uint256 x;
    uint256 y;
    uint256 z;
}

These struct definitions might reside in different files within complex projects, entering the inheritance hierarchy at various junctures. This lack of direct visibility can lull developers into believing that merely appending a new variable to the end of a struct is a safe method for upgrading the data structure.

1
2
3
4
5
contract Meh {
    Foo public st1;
    Bar public st2;
    ...
}

In the above scenario, extending the Foo struct unintentionally interferes with the storage slots of the Bar struct. If a new variable is added to Foo, it will end up overwriting st2.x — a severe issue.

The intricacy of this problem intensifies when third-party libraries are incorporated into the mix. Each library and every contract in the inheritance tree must be compatible with upgrades. This could lead to more complex and convoluted code structures and potentially introduce more problematic bugs to identify and rectify.

The absence of visibility within a complex inheritance tree, primarily when different data is defined in other source units, further compounds this problem. Developers might not quickly notice conflicts, leading to oversights that could result in significant storage issues during an upgrade.

Storage handling in Solidity exacerbates this issue. Upon declaring a struct, a new storage slot is always used. Each subsequent variable definition is then tightly packed into storage, consuming a single slot for multiple elements where possible, similar to standard storage variables.

To avoid this problem, a strategic approach involves including a uint256[] _gap variable at the end of each struct. Given the limit of 16 properties for structs due to stack size, a uint[9] _gap at the end of the Foo struct and a uint256[14] _gap at the end of the Bar struct can be added respectively. As new properties are introduced to each storage layout, the _gap size can be reduced accordingly.

However, this solution has its own implications. If the smart contract loads the entire struct from storage, the additional gap slots will also be loaded. They will inflate the gas cost by 2100 per slot — 2000 more than touching a hot storage slot if untouched. Conversely, only the SLOAD gas cost for the retrieved data is incurred when accessing a property directly, and the gap slots are not loaded.

Implementing upgradeability in smart contracts is invariably fraught with complexity, with storage layout being a key concern. It's essential to carefully design and manage the storage layout throughout the upgrades to prevent grave issues such as data corruption or a "bricked" contract.

Additionally, testing is an often overlooked but vital practice. Performing tests on upgrade functionality and conducting dry runs can significantly reduce the risk of errors during an actual upgrade. Thorough planning, meticulous design, and rigorous testing become indispensable when dealing with storage layouts and third-party libraries in an upgradeable contract.

Given these challenges, a cautious approach to upgradeability is recommended. It may be best to avoid upgrades altogether. Complex storage corruption bugs can be categorically excluded by designing immutable and trustless systems, and the system stays true to the spirit of decentralization that underpins blockchain technology.