Understanding How to Write Upgradable Smart Contracts

Explore the importance of upgradable smart contracts in decentralized applications. Learn how they enable updates, bug fixes, and enhanced security through the proxy pattern.

by MetaMask Developer, Alejandro MenaOctober 3, 2024
Upgradable Smart Contract Feature Image

Smart contracts have changed the way we think about decentralized applications by introducing self-executing programs that eliminate the need for intermediaries. They execute transparently on decentralized blockchain networks, offering benefits like trustlessness and increased security. However, their immutability once considered a strength, poses a significant challenge: once a smart contract is deployed, it can't be altered. This rigidity makes implementing necessary updates or fixing bugs difficult.

In this post, we'll explore the concept of upgradable smart contracts, explain why they are essential, and break down how they work, particularly focusing on their implementation on Linea zkEVM.

What are upgradable smart contracts?

Upgradable smart contracts solve the limitations of traditional smart contracts by allowing modifications and improvements after deployment. While traditional smart contracts are known for their immutability, upgradable contracts combine the security of immutability with the flexibility to adapt. This allows developers to implement updates without disrupting users or requiring them to migrate to a new contract.

In traditional contracts, bugs or security flaws are permanent once deployed. Upgradable contracts address this by enabling updates after deployment, solving key issues such as:

1. Bug fixes and security vulnerabilities: Developers can patch vulnerabilities post-deployment, safeguarding user assets.

2. Adaptability: Upgradable contracts allow for seamless integration with new protocols, keeping applications up-to-date with technological advances.

3. Security enhancements: With evolving cyber threats, these contracts make it easy to implement new security measures without redeploying the entire contract.

4. Governance flexibility: Decentralized Autonomous Organizations (DAOs) can use community voting to manage the upgrade process, ensuring transparency and stakeholder participation.

5. Simplified updates: Unlike traditional contracts that require complex redeployment and data migration, upgradable contracts streamline updates for a more efficient and user-friendly experience.

How do upgradable smart contracts work?

To create an upgradable smart contract, the most common method is the proxy pattern. This approach separates the contract's logic from its data, allowing developers to update the logic while keeping the same data storage.

Here’s how it works:

  1. Proxy Contract: This contract holds the storage (state variables) and acts as the interface for users.
  2. Implementation Contract: This contract contains the executable code (business logic).
  3. Delegation: The proxy contract delegates function calls to the implementation contract using Ethereum’s delegatecall. This allows the proxy to execute the implementation’s code while retaining the same storage.

When an upgrade is required, the proxy contract is directed to a new implementation contract, enabling logic updates without changing the contract's storage or disrupting users. Here's an example using a DAO proxy contract to illustrate this process:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract DAOProxy {
    // Storage slot for the address of the implementation contract
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    // Storage slot for the admin address
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");

    // Modifier to restrict access to admin
    modifier onlyAdmin() {
        require(msg.sender == _admin(), "Caller is not admin");
        _;
    }

    // Constructor to set the initial implementation and admin
    constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }

    // Fallback function to delegate calls to the implementation
    fallback() external payable {
        _delegate(implementation());
    }

    // Receive function to handle plain Ether transfers
    receive() external payable {
        _delegate(implementation());
    }

    // Internal function to delegate the call to the implementation
    function _delegate(address _implementation) internal {
        assembly {
            // Copy msg.data
            calldatacopy(0, 0, calldatasize())
            // Call the implementation
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            // Copy the returned data
            returndatacopy(0, 0, returndatasize())
            // Handle result
            switch result
            case 0 {
                // Revert if the call failed
                revert(0, returndatasize())
            }
            default {
                // Return data if the call succeeded
                return(0, returndatasize())
            }
        }
    }

    // Getter for the implementation address
    function implementation() internal view returns (address impl) {
        bytes32 slot = implementationSlot;
        assembly {
            impl := sload(slot)
        }
    }

    // Getter for the admin address
    function _admin() internal view returns (address adm) {
        bytes32 slot = adminSlot;
        assembly {
            adm := sload(slot)
        }
    }

    // Function to upgrade the implementation contract
    function upgradeTo(address newImplementation) external onlyAdmin {
        _setImplementation(newImplementation);
    }

    // Internal function to set the implementation address
    function _setImplementation(address newImplementation) internal {
        bytes32 slot = implementationSlot;
        assembly {
            sstore(slot, newImplementation)
        }
    }

    // Function to change admin
    function changeAdmin(address newAdmin) external onlyAdmin {
        _setAdmin(newAdmin);
    }

    // Internal function to set the admin address
    function _setAdmin(address newAdmin) internal {
        bytes32 slot = adminSlot;
        assembly {
            sstore(slot, newAdmin)
        }
    }
}
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");

Two important things to note are the storage slot for the address of the implementation contract and the storage slot for the admin address. These are deliberate and unique identifiers used to compute a storage slot for the implementation address within the proxy contract.

When using the proxy pattern with delegatecall (we’ll take a closer look at this function soon), the implementation contract operates in the context of the proxy's storage. This means:

  • Shared storage space: Both contracts share the same storage layout.
  • Risk of overwriting: Without careful management, variables in the proxy contract could be overwritten by the implementation contract.

By using a hashed unique identifier:

  • Isolation: It ensures that the storage slots used by the proxy are unlikely to conflict with any variables in the implementation contract.
  • Consistency: The same storage slot is consistently used throughout the contract wherever the implementation address is needed.
constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }

Another important aspect is the constructor of the proxy contract. It includes an _implementation parameter, meaning the implementation contract must be deployed first, and its address is passed as an argument when deploying the proxy contract.

 fallback() external payable {
        _delegate(implementation());
    }

    receive() external payable {
        _delegate(implementation());
    }

Now we have the fallback and receive functions, these are Solidity functions and play crucial roles in forwarding calls and Ether transfers to the implementation contract. They handle situations where a contract receives calls or funds that do not match any of its explicitly defined functions. Here's an in-depth explanation of what these functions do:

1. The fallback function

  • Fallback mechanism: The fallback function in Solidity is a special function that executes under two main conditions:
    • When a function that does not exist in the contract is called.
    • When data is sent to the contract without matching any function signature.
  • Delegation: In the context of the DAOProxy contract, the fallback function delegates all such calls to the current implementation contract.

How it works in DAOProxy:

  • Function call handling:
    • When a user calls a function not defined in the proxy contract, the fallback function is triggered.
    • This is common because the proxy contract mainly contains administrative functions like upgradeTo and changeAdmin, rather than the business logic functions of the implementation contract.
  • Delegation to implementation:
    • The fallback function calls _delegate(implementation()), forwarding the call to the implementation contract.
    • _delegate is an internal function that uses delegatecall to execute the implementation's code while operating in the proxy's storage context.
  • Execution context:
    • All state changes happen in the proxy's storage.
    • msg.sender and msg.value remain unchanged, preserving the original caller's context.

2. The receive function

  • Ether reception: The receive function, introduced in Solidity 0.6.0, is executed when a contract receives Ether without any accompanying data (i.e., when msg.data is empty).
  • **Delegation of Ether transfers:**In the DAOProxy contract, plain Ether transfers are delegated to the implementation contract.

How it works in DAOProxy:

  • Ether transfer handling:
    • When someone sends Ether to the proxy contract’s address without data, the receive function is triggered.
    • This happens through a transaction that specifies only the to address and value (Ether amount), without calling any specific function.
  • Delegation to implementation:
    • Like the fallback function, receive calls _delegate(implementation()), forwarding the Ether transfer to the implementation contract.
    • The implementation contract then defines how to handle the received Ether (e.g., updating balances, emitting events).
 function _delegate(address _implementation) internal {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }

Now we have the _delegate function, which enables the proxy to forward function calls to the implementation contract while preserving the context of the original call. This allows the contract logic to be upgraded over time without changing the contract's address or disrupting its state.

The other functions are getters and setters that help us manage the state variables of our proxy contract. To achieve this, we use the assembly keyword, which allows developers to include low-level assembly code directly within their contracts. This feature provides direct access to Ethereum Virtual Machine (EVM) instructions, enabling more precise control over contract behavior, optimization of gas consumption, and execution of operations that are either not directly available or are less efficient in Solidity's high-level code.

Now, the big question is: how do we interact with our implementation smart contract using this proxy contract?

To do this, we need the implementation’s smart contract ABI. Since the proxy delegates the calls to the implementation contract, you need its ABI to know which functions you can call. Additionally, we’ll use the address of the proxy contract when interacting with the blockchain, not the implementation contract. The proxy holds the state and delegates calls to the implementation, so all interactions should go through the proxy.

Let’s imagine we have a JavaScript project, and we’re using Ethers.js to interact with the blockchain. Here’s how it would look:

const proxyAddress = "0xProxyContractAddress";
const implementationABI = [ /* ABI array of the implementation contract */ ];

const proxyContract = new ethers.Contract(proxyAddress, implementationABI, provider);

// For a function 'setValue(uint256 newValue)'
await proxyContract.connect(signer).setValue(42);

// For a function 'getValue()'
const value = await proxyContract.getValue();
console.log("Value:", value);

This way, we’re calling the imaginary function setValue in our implementation contract using our proxy contract.

Now that we’re discussing upgradeable contracts, another important question arises: how do I upgrade my contracts?

First, only the admin (the person who deployed the proxy contract) can upgrade the implementation contract. To do this, the admin must first deploy a new version of the implementation contract to the blockchain. After that, they should call the `upgradeTo` function in the proxy contract, passing the new contract address as a parameter. It would look like this:

await proxyContract.connect(adminSigner).upgradeTo(newImplementationAddress);

Embracing the future of smart contracts

Upgradeable smart contracts are important in blockchain development because they allow for changes and improvements after deployment. By leveraging the proxy pattern, developers can seamlessly introduce new features, fix bugs, and respond to changing requirements without disrupting the user experience or migrating assets.

However, with great power comes great responsibility. It's crucial to approach upgradeable contracts with a thorough understanding of their mechanics, security implications, and best practices. Ensuring storage layout consistency, implementing robust access controls, and thoroughly testing upgrades are essential steps to safeguard against potential risks.

As you venture into building upgradeable contracts, consider utilizing established tools like OpenZeppelin's Upgrades Plugin to streamline the process and enhance security. Happy coding!

Receive our Newsletter