Upgradeability Patterns in Solidity

Upgradeability Patterns in Solidity

In Solidity, once a smart contract is deployed, its code is immutable. This means you cannot directly modify the contract logic. However, as applications evolve, there’s often a need to update or upgrade a contract without losing critical data. This is where upgradeability patterns come into play.

In this blog, we’ll focus on two popular upgradeability patterns: Proxy Delegate and Eternal Storage. These techniques allow you to create smart contracts that can evolve over time without compromising the integrity of your data.

1. Proxy Delegate Pattern

The Proxy Delegate pattern is the most widely used upgradeability technique in Solidity. It involves splitting the smart contract into two components:

  • Proxy Contract (Data Layer): Stores all the data (state variables) and delegates execution to the logic contract.

  • Logic Contract (Logic Layer): Contains the actual business logic, which can be upgraded without changing the proxy contract.

How Does It Work?

  • The Proxy Contract acts as the single point of interaction for users. It uses the delegatecall opcode to forward function calls to the Logic Contract.

  • With delegatecall, the Logic Contract's code is executed in the context of the Proxy Contract, meaning it has access to the Proxy's state.

Benefits

  • Upgradeable Logic: By pointing the Proxy Contract to a new Logic Contract, you can upgrade the contract functionality without redeploying the data layer.

  • Single Address: Users always interact with the same Proxy Contract address, so no need to reconfigure external integrations.

Implementation Example

Proxy Contract:

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

contract Proxy {
    address public logicContract; // Stores the address of the logic contract

    constructor(address _logicContract) {
        logicContract = _logicContract;
    }

    function updateLogic(address _newLogic) external {
        logicContract = _newLogic; // Allow updating the logic contract
    }

    fallback() external payable {
        (bool success, bytes memory result) = logicContract.delegatecall(msg.data);
        require(success, "Delegatecall failed");
    }
}

Logic Contract (Version 1):

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

contract LogicV1 {
    uint256 public value;

    function setValue(uint256 _value) public {
        value = _value;
    }
}

Logic Contract (Version 2 - Upgrade):

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

contract LogicV2 {
    uint256 public value;

    function setValue(uint256 _value) public {
        value = _value * 2; // Updated logic
    }
}

To better understand the Proxy Delegate Pattern, let’s implement a more realistic example where the contract manages a user registry. This will showcase how upgradeability works in practice.


Use Case: A User Management System that stores user data and can be upgraded to include new functionality.


Step 1: Proxy Contract

The proxy contract delegates all function calls to the logic contract. Here’s how it looks:

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

contract Proxy {
    address public logicContract; // Address of the logic contract
    address public owner;        // Owner of the proxy

    constructor(address _logicContract) {
        logicContract = _logicContract;
        owner = msg.sender;
    }

    // Modifier to restrict access to the owner
    modifier onlyOwner() {
        require(msg.sender == owner, "Not the owner");
        _;
    }

    // Update the logic contract address
    function updateLogic(address _newLogic) external onlyOwner {
        logicContract = _newLogic;
    }

    // Delegate all calls to the logic contract
    fallback() external payable {
        (bool success, bytes memory data) = logicContract.delegatecall(msg.data);
        require(success, "Delegatecall failed");
    }
}

Step 2: Logic Contract (Version 1)

This is the initial logic for managing users.

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

contract UserManagementV1 {
    struct User {
        string name;
        uint256 age;
    }

    mapping(address => User) private users;

    function registerUser(string memory _name, uint256 _age) public {
        users[msg.sender] = User(_name, _age);
    }

    function getUser() public view returns (string memory, uint256) {
        User memory user = users[msg.sender];
        return (user.name, user.age);
    }
}

Step 3: Logic Contract (Version 2 - Upgrade)

The upgraded contract introduces a new feature: updating a user's name.

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

contract UserManagementV2 {
    struct User {
        string name;
        uint256 age;
    }

    mapping(address => User) private users;

    function registerUser(string memory _name, uint256 _age) public {
        users[msg.sender] = User(_name, _age);
    }

    function updateUserName(string memory _newName) public {
        users[msg.sender].name = _newName;
    }

    function getUser() public view returns (string memory, uint256) {
        User memory user = users[msg.sender];
        return (user.name, user.age);
    }
}

Step 4: Deploy and Upgrade

  1. Deploy UserManagementV1.

  2. Deploy Proxy and pass the address of UserManagementV1 as _logicContract.

  3. Interact with the proxy contract to register and fetch users.

  4. Deploy UserManagementV2.

  5. Update the logic contract in the proxy using updateLogic to point to UserManagementV2.

  6. Interact with the new logic (e.g., updateUserName).

Step 5: Testing Example

After upgrading:

  • User data remains intact.

  • You can call new methods like updateUserName.

Proxy Delegate Pattern Pitfalls

  1. Storage Misalignment

    • Issue: Proxy and Logic contracts share the same storage. If the storage layout of the logic contract changes (e.g., variables added, removed, or reordered), it can corrupt data.

    • Mitigation:

      • Always maintain the same storage layout in all logic contract versions.

      • Use reserved storage slots in the initial design for potential future variables.

  2. Delegatecall Security Risks

    • Issue: delegatecall executes the logic contract code in the Proxy's context, exposing the Proxy's storage and allowing malicious logic contracts to manipulate critical state.

    • Mitigation:

      • Implement strict access control for upgrading logic contracts (e.g., only admin or multisig can upgrade).

      • Audit all logic contracts thoroughly for malicious or unintended behavior.

  3. Upgrade Downtime

    • Issue: During an upgrade, there might be a short period when users cannot interact with the system.

    • Mitigation:

      • Plan upgrades during low-traffic periods.

      • Notify users of planned maintenance windows.

  4. Hardcoded ABI Dependency

    • Issue: Proxy contracts depend on the ABI of the currently active logic contract. If the logic changes significantly, the client code must also be updated.

    • Mitigation:

      • Maintain a version-controlled interface library.

      • Use tools like OpenZeppelin's Proxy Admin to manage upgrades systematically.

  5. Increased Gas Costs

    • Issue: Proxies introduce an additional layer of computation for every call, increasing gas usage.

    • Mitigation:

      • Optimize logic contract code.

      • Avoid unnecessary delegation for frequent low-cost operations.

  6. Unintended Initialization

    • Issue: Logic contracts may unintentionally initialize the Proxy's state if not designed carefully.

    • Mitigation:

      • Include an initializer modifier in the logic contract to prevent re-initialization.

      • Example:

          bool private initialized;
        
          modifier initializer() {
              require(!initialized, "Already initialized");
              initialized = true;
              _;
          }
        

2. Eternal Storage Pattern

The Eternal Storage pattern focuses on separating the data storage from the contract logic. This ensures that the data remains intact even when the logic is replaced.

How Does It Work?

  • A dedicated Storage Contract is deployed to hold all state variables.

  • The Logic Contract interacts with the Storage Contract via getter and setter functions.

Benefits

  • Decoupled Logic and Storage: The separation simplifies upgrades and reduces the risk of accidentally overwriting critical state variables.

  • Long-Term Data Persistence: Data is not tied to a specific Logic Contract, making upgrades more flexible.

Implementation Example

// Eternal Storage Contract
pragma solidity ^0.8.0;

contract EternalStorage {
    mapping(bytes32 => uint256) private uintStorage;

    function getUint(bytes32 key) external view returns (uint256) {
        return uintStorage[key];
    }

    function setUint(bytes32 key, uint256 value) external {
        uintStorage[key] = value;
    }
}
// Logic Contract
pragma solidity ^0.8.0;

import "./EternalStorage.sol";

contract LogicV1 {
    EternalStorage private storageContract;

    constructor(address _storageContract) {
        storageContract = EternalStorage(_storageContract);
    }

    function setValue(uint256 _value) external {
        bytes32 key = keccak256(abi.encodePacked("value"));
        storageContract.setUint(key, _value);
    }

    function getValue() external view returns (uint256) {
        bytes32 key = keccak256(abi.encodePacked("value"));
        return storageContract.getUint(key);
    }
}
  1. Deploy the EternalStorage contract.

  2. Deploy the LogicV1 contract, passing the address of the EternalStorage contract.

  3. To upgrade, deploy LogicV2 and use the same EternalStorage contract for data access.

Key Points to Remember

  • Data Access Overhead: The Eternal Storage pattern requires additional steps to access data, which may increase gas costs.

  • Unique Keys: Use unique, descriptive keys for each variable to avoid collisions.

Let’s create a Token System using the Eternal Storage pattern.


Use Case: A Token System where token balances are managed separately from the logic. Future upgrades might change token logic without altering the storage.


Step 1: Eternal Storage Contract

The storage contract stores token balances and total supply.

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

contract EternalStorage {
    mapping(bytes32 => uint256) private uintStorage;

    function getUint(bytes32 key) public view returns (uint256) {
        return uintStorage[key];
    }

    function setUint(bytes32 key, uint256 value) public {
        uintStorage[key] = value;
    }
}

Step 2: Token Logic (Version 1)

The initial logic allows minting tokens and checking balances.

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

import "./EternalStorage.sol";

contract TokenV1 {
    EternalStorage private eternalStorage;

    bytes32 private constant TOTAL_SUPPLY = keccak256("totalSupply");
    bytes32 private constant BALANCE_PREFIX = keccak256("balance");

    constructor(address _storageAddress) {
        eternalStorage = EternalStorage(_storageAddress);
    }

    function mint(address _to, uint256 _amount) public {
        uint256 currentSupply = eternalStorage.getUint(TOTAL_SUPPLY);
        eternalStorage.setUint(TOTAL_SUPPLY, currentSupply + _amount);

        bytes32 balanceKey = keccak256(abi.encodePacked(BALANCE_PREFIX, _to));
        uint256 currentBalance = eternalStorage.getUint(balanceKey);
        eternalStorage.setUint(balanceKey, currentBalance + _amount);
    }

    function balanceOf(address _account) public view returns (uint256) {
        bytes32 balanceKey = keccak256(abi.encodePacked(BALANCE_PREFIX, _account));
        return eternalStorage.getUint(balanceKey);
    }
}

Step 3: Token Logic (Version 2 - Upgrade)

The upgraded version adds the ability to burn tokens.

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

import "./EternalStorage.sol";

contract TokenV2 {
    EternalStorage private eternalStorage;

    bytes32 private constant TOTAL_SUPPLY = keccak256("totalSupply");
    bytes32 private constant BALANCE_PREFIX = keccak256("balance");

    constructor(address _storageAddress) {
        eternalStorage = EternalStorage(_storageAddress);
    }

    function mint(address _to, uint256 _amount) public {
        uint256 currentSupply = eternalStorage.getUint(TOTAL_SUPPLY);
        eternalStorage.setUint(TOTAL_SUPPLY, currentSupply + _amount);

        bytes32 balanceKey = keccak256(abi.encodePacked(BALANCE_PREFIX, _to));
        uint256 currentBalance = eternalStorage.getUint(balanceKey);
        eternalStorage.setUint(balanceKey, currentBalance + _amount);
    }

    function burn(address _from, uint256 _amount) public {
        uint256 currentSupply = eternalStorage.getUint(TOTAL_SUPPLY);
        eternalStorage.setUint(TOTAL_SUPPLY, currentSupply - _amount);

        bytes32 balanceKey = keccak256(abi.encodePacked(BALANCE_PREFIX, _from));
        uint256 currentBalance = eternalStorage.getUint(balanceKey);
        eternalStorage.setUint(balanceKey, currentBalance - _amount);
    }

    function balanceOf(address _account) public view returns (uint256) {
        bytes32 balanceKey = keccak256(abi.encodePacked(BALANCE_PREFIX, _account));
        return eternalStorage.getUint(balanceKey);
    }
}

Step 4: Testing Example

  1. Deploy EternalStorage.

  2. Deploy TokenV1, passing the storage contract address.

  3. Mint tokens and check balances.

  4. Deploy TokenV2, passing the same storage contract.

  5. Use the new functionality (burn) while maintaining balances.

Eternal Storage Pattern Pitfalls

  1. Incorrect Key Management

    • Issue: Using different keys for the same variables across logic contracts can result in data inconsistency.

    • Mitigation:

      • Standardize key naming conventions using keccak256("variableName").

      • Store keys as constants in the logic contract.

  2. Increased Gas Costs

    • Issue: Eternal Storage introduces external contract calls for every read/write, which can be expensive.

    • Mitigation:

      • Optimize access patterns (e.g., batch operations where possible).

      • Cache frequently used values in memory during execution.

  3. Logic Contract Lock-In

    • Issue: Logic contracts are tightly coupled to the Eternal Storage contract, making it difficult to migrate to a new storage mechanism.

    • Mitigation:

      • Design an efficient migration function to extract and transfer data if needed.

      • Example:

          solidityCopy codefunction migrateData(address newStorage) external onlyAdmin {
              uint256 value = eternalStorage.getUint(keccak256("value"));
              EternalStorage(newStorage).setUint(keccak256("value"), value);
          }
        
  4. State Pollution

    • Issue: Leftover keys in the storage contract from old logic contracts can create "ghost state."

    • Mitigation:

      • Use tools to analyze storage usage and remove unused keys periodically.

      • Maintain a registry of active keys for each logic contract.

  5. Version Incompatibility

    • Issue: Changes in logic contract structure may cause compatibility issues with stored data.

    • Mitigation:

      • Version-control your logic contracts and document state dependencies.

      • Use adapters to handle differences between logic contract versions.

  6. Contract Maintenance Overhead

    • Issue: Managing multiple contracts (Eternal Storage + Logic) increases complexity.

    • Mitigation:

      • Automate deployment and testing with scripts.

      • Use frameworks like Hardhat or Truffle for managing interactions.

  7. Admin Access Exploits

    • Issue: Admin accounts can modify Eternal Storage keys maliciously or accidentally.

    • Mitigation:

      • Use a multisig wallet for administrative actions.

      • Limit admin functions to emergency scenarios.

  8. Key Collision

    • Issue: Different variables from separate logic contracts might accidentally use the same storage key.

    • Mitigation:

      • Use unique namespaces for keys, such as keccak256("ContractName.variableName").

Comparison: Proxy Delegate vs. Eternal Storage

FeatureProxy DelegateEternal Storage
Data StorageStorage in the proxy contractStorage in a separate contract
Upgrade ComplexityModerate (storage layout compatibility)High (key management and access)
Gas CostsLowerHigher (external storage calls)
FlexibilityTightly coupled to proxyModular, supports multiple logics

General Best Practices

  1. Testing

    • Extensively test upgrades in a staging environment.

    • Simulate real-world usage to uncover potential edge cases.

  2. Code Audits

    • Conduct regular audits, especially before deploying new logic contracts.

    • Use automated tools (MythX, Slither) and manual code reviews.

  3. Transparent Communication

    • Communicate with users and stakeholders about upgrades and their impact.

    • Implement versioning in the ABI and public interfaces.

  4. Upgradeability Frameworks

    • Use established frameworks like OpenZeppelin's Upgradeable Contracts library for safer upgrades.

    • Example: OpenZeppelin provides a transparent upgradeable proxy that simplifies many of these issues.

Conclusion

Both Proxy Delegate and Eternal Storage patterns enable contract upgradeability, but they suit different use cases. Use Proxy Delegate for simplicity and cost-effectiveness, and Eternal Storage for more modular systems where data must be shared across multiple contracts. Understanding these patterns equips developers to build robust and upgradeable blockchain applications.