Delegatecall and Upgradeable Contracts: A Comprehensive Guide

ยท

Table of Contents

  1. Introduction to Delegatecall

  2. Delegatecall Applications: Upgradeable Contracts

  3. Limitations of Upgradeable Logic Contracts

Introduction to Delegatecall

Ethereum founder Vitalik Buterin introduced delegatecall in EIP-7. When Contract A uses delegatecall to call Contract B, it uses Contract A's storage (not Contract B's). This means if the called function modifies state variables, these changes will actually affect Contract A.

Key applications of delegatecall include: 1) upgradeable contracts, and 2) Solidity's Linked Library. This guide focuses on upgradeable contracts.

Delegatecall Demonstration

Consider these two contracts:

ContractA:

pragma solidity 0.6.12;
contract ContractA {
    address public contractB;
    constructor(address _contractB) public {
        contractB = _contractB;
    }
    function delegatecallChangeZ(uint256 _z) public {
        contractB.delegatecall(abi.encodeWithSignature("changeZ(uint256)", _z));
    }
    function delegatecallChangeX(uint256 _x) public {
        contractB.delegatecall(abi.encodeWithSignature("changeX(uint256)", _x));
    }
    function getValue(uint256 slot) view public returns(uint256 value) {
        assembly { value := sload(slot) }
    }
}

ContractB:

pragma solidity 0.6.12;
contract ContractB {
    uint256 public x;
    uint256 public y;
    uint256 public z;
    function changeX(uint256 _x) public { x = _x; }
    function changeY(uint256 _y) public { y = _y; }
    function changeZ(uint256 _z) public { z = _z; }
}

After calling delegatecallChangeZ(5), ContractB's z remains unchanged. Instead, ContractA's storage slot 2 (third slot) is modified to 5, which can be verified by calling getValue(2).

๐Ÿ‘‰ Learn more about delegatecall implementation

Avoiding Storage Clashes

Calling delegatecallChangeX would modify ContractA's first state variable (contractB address) instead of ContractB's x. This demonstrates the danger of storage clashes in delegatecall operations.

Differences Between Call and Delegatecall

The key difference is that delegatecall uses the caller's storage context. Figure 3 illustrates how address(this) and msg.sender behave differently in call vs delegatecall operations.

Upgradeable Contracts

The fundamental principle is using ContractA as a Proxy contract and ContractB as an Implementation contract. The Proxy stores state variables while the Implementation contains business logic. To upgrade, simply change the Implementation address in the Proxy.

Proxy Contracts

A basic Proxy contract implementation:

contract Proxy {
    bytes32 private constant _IMPLEMENTATION_SLOT = 0x36089...;
    bytes32 private constant _ADMIN_SLOT = 0xb5312...;
    
    constructor() {
        address admin = msg.sender;
        assembly { sstore(_ADMIN_SLOT, admin) }
    }
    
    function _delegate() internal {
        address _implementation = implementation();
        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()) }
        }
    }
    
    fallback() payable external { _delegate(); }
    receive() payable external { _delegate(); }
}

Solution to Problem 1: EIP-1967

EIP-1967 defines specific storage slots for Proxy contracts to avoid clashes:

Solution to Problem 2: Transparent Proxy Pattern

The Transparent Proxy Pattern implements:

  1. All end-user calls are forwarded to the Implementation contract
  2. Admin calls are never forwarded

This prevents "Proxy selector clashing" where calls to Proxy methods bypass the Implementation.

๐Ÿ‘‰ Best practices for upgradeable contracts

Upgradeable Contract Architecture

Architecture Summary

The transparent proxy architecture is shown in Figure 6, where users interact with the Proxy contract which forwards requests to the Implementation.

Unified Upgrades for Multiple Proxies (Beacon Proxy Pattern)

For scenarios with multiple Proxy contracts sharing one Implementation, the Beacon Proxy Pattern introduces a Beacon contract that stores the Implementation address. Upgrading the Implementation requires just one change to the Beacon contract.

OpenZeppelin Contracts

OpenZeppelin provides robust implementations of these patterns that handle upgrades securely.

Other Upgradeable Architectures

UUPS Standard

The UUPS pattern (EIP-1822) differs by placing upgrade logic in the Implementation contract rather than the Proxy. Benefits include:

Example Implementation:

contract MyToken is UUPSUpgradeable, AccessControlEnumerableUpgradeable {
    function _authorizeUpgrade(address) internal override onlyRole(DEFAULT_ADMIN_ROLE) {}
}

Diamond (Multi-Facet Proxy)

EIP-2535 enables upgrading individual functions through a modular approach, though it's more complex to implement.

Architecture Comparison

PatternProsCons
TransparentSimple, widely usedHigher gas costs
UUPSGas efficient, flexibleNewer, requires careful access control
DiamondModular, avoids size limitsComplex implementation

Limitations

No Constructors

Instead of constructors, use initializer functions:

contract MyContract is Initializable {
    function initialize() public initializer {
        // Initialization code
    }
}

Base Contract Initializers

Remember to call base contract initializers:

function initialize() public initializer {
    BaseContract.initialize();
}

No Initial Values

Move initial values to initializer functions:

uint256 public hasInitialValue;
function initialize() public initializer {
    hasInitialValue = 42;
}

Modification Restrictions

When modifying contracts:

FAQs

What is delegatecall in Ethereum?

Delegatecall is a low-level function that executes code from another contract while maintaining the caller's storage context. This enables patterns like upgradeable contracts.

Why can't upgradeable contracts use constructors?

Constructors only execute during deployment and don't affect the proxy's storage. Initializer functions must be used instead to properly initialize state.

What's the difference between UUPS and transparent proxies?

UUPS places upgrade logic in the implementation contract, making proxies more lightweight. Transparent proxies contain upgrade logic but are simpler to implement.

How can I upgrade multiple proxy contracts at once?

The Beacon Proxy Pattern allows upgrading multiple proxies simultaneously by having them reference a single beacon contract that stores the implementation address.

What are the main risks with upgradeable contracts?

Storage clashes and accidental overrides are key risks. Following EIP-1967 for storage slots and using established patterns like Transparent Proxy or UUPS mitigates these risks.