Skip to content

Upgradeability in Smart Contracts: Recommendations and Security

Smart contract development is difficult primarily because the code is immutable. Once deployed, the code becomes fixed and unchangeable, and so do any bugs it contains. Developers therefore incorporate emergency upgrade functionality to keep vital security fixes possible.

Upgradeability carries its own complexities, from technical intricacies through to unforeseen problems arising from the upgrade process itself. The following sections work through them.

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. Software development, where bugs and vulnerabilities are inevitable and demand prompt fixes, contradicts that guarantee directly. Blockchain applications are written in languages that are not exempt from security vulnerabilities, which is where the paradox originates.

In a mutable software environment, a developer implements a fix or releases a new version to address a discovered vulnerability. On-chain software demands 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 an authority with the power to decide what to update and when. That authority is either a centralized owner, which raises the risk of private key loss, or a more complex construct such as a multisig or DAO, which significantly increases overall system complexity.

Furthermore, upgradeability is somewhat contradictory to the decentralization ethos of blockchain technology, as it allows implementations to change unexpectedly. A user sending a transaction to an upgradeable system, where an upgrade lands in the transaction immediately preceding theirs, has their transaction 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.

Avoiding upgradeability altogether, in favor of alternatives such as migrations, is the cleanest resolution. It demands more due diligence before release and produces trustless software and stronger 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."

The following diagram shows 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.

Proxies facilitate contract upgrades and introduce additional complexity and security risks alongside them. Weigh the need for upgradeability against those risks carefully.

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.

UUPS proxies carry a significant caveat. Upgrading the proxy to a logic contract that does not inherit the upgrade functionality makes any further upgrade impossible, which demands careful planning and meticulous checking at deployment time.

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, only the implementation contract address stored in the beacon needs upgrading.

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. After an upgrade, every proxy referring to the beacon immediately references the new contract, which upgrades all associated proxies at once.

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

Upgrades pose unique challenges around struct growth and storage collisions. Extending a struct is an often overlooked source of trouble.

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;
}

In complex projects these struct definitions reside in different files and enter the inheritance hierarchy at various junctures. The lack of direct visibility lulls developers into treating an appended variable at the end of a struct as a safe upgrade.

1
2
3
4
5
6
contract Vault {
    // @audit extending Foo shifts every slot Bar occupies
    Foo public st1;
    Bar public st2;
    // ...
}

Extending the Foo struct interferes with the storage slots of the Bar struct. A new variable added to Foo overwrites st2.x.

Third-party libraries intensify the problem, since every library and every contract in the inheritance tree has to remain upgrade-compatible, which produces more convoluted code structures and correspondingly harder bugs.

Poor visibility inside a complex inheritance tree compounds it further, particularly where the data is defined in other source units. Conflicts go unnoticed, and the oversight surfaces as a storage issue 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.

Including a uint256[] _gap variable at the end of each struct avoids the problem. 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.

The solution has its own implications. A smart contract loading the entire struct from storage loads the gap slots along with it, which inflates the gas cost by 2100 per untouched slot, 2000 more than touching a hot storage slot. Accessing a property directly incurs only the SLOAD cost for the retrieved data and leaves the gap slots unloaded.

Upgradeability is invariably fraught with complexity, and storage layout is the key concern. Design and manage the layout carefully across upgrades, which is what prevents data corruption and bricked contracts.

Testing is the other vital and often overlooked practice. Tests against the upgrade functionality and dry runs of the upgrade itself substantially reduce the risk of errors during the real one. Thorough planning, meticulous design, and rigorous testing are indispensable wherever storage layouts and third-party libraries meet an upgradeable contract.

A cautious approach follows from all of this, and avoiding upgrades altogether remains the strongest option. Immutable and trustless systems exclude complex storage corruption bugs categorically and stay true to the spirit of decentralization underpinning blockchain technology.