Modular (& Shiny) Smart Contracts: EIP-2535 Diamonds

EIP-2535 Diamonds revolutionize smart contract mutability and upgradeability on Ethereum. This in-depth article elucidates the Diamond Standard, explaining the Proxy Pattern, Diamond's multi-faceted structure, and the key benefits it brings to blockchain development. By adopting EIP-2535, developers can manage limitless contract sizes, modular code, and efficient upgrades, ensuring adaptability in an immutable ecosystem.

💡 Articles
22 December 2023
Article Image

Humans are fallible creatures, software engineers even more so. Most code produced by an engineer starts out riddled with bugs. Nature of the job, you know.

But blockchain’s nature stands in stark contrast: smart contracts are immutable. This immutability is what makes the whole ecosystem trustless in the first place, but it also makes it impractical. Remember, no piece of code will start out bug-free.

What do you do?

You allow mutability 🙃. You allow upgradeable smart contracts.

💡 In case of an upgradeable smart contract, trustlessness is ensured either through DAO governance or by eventually making the contract immutable.

Proxy Pattern

On most EVM chains, once you deploy a smart contract, there’s no way to alter it. Whatever mistakes you make, you’ve got to live with them.

But then came the Proxy Pattern. Here’s how it works.

Pretty simply, huh. All your code/logic lives inside the implementation contract whereas all the state/data is hosted by the proxy contract. Your code is decoupled from your state.

All transactions are only ever submitted to the proxy contract, which utilizes the delegatecall opcode to invoke the implementation contract…in the proxy’s context. What this means is that the implementation contract has no state of its own and instead hooks into the proxy’s state storage when invoked specifically through delegatecall. Courtesy of the EVM.

But how does it achieve upgradeability?

Easy! Deploy a new implementation contract and point the proxy contract to this new instance. Since the state is hosted by the proxy contract, it remains preserved.

Diamond: Multi-Facet Proxy

A Diamond works exactly like the proxy pattern; it stores the data of the smart contract and uses the Solidity fallback function to make delegatecalls to Facets that contain the actual logic code.

In other words, a multi-faceted proxy contract is called a Diamond.

The distinction between a Diamond and a Proxy is not trivial though, at least when talking purely in terms of code/implementation.

  • A Diamond needs to correctly handle the call delegations i.e., it needs to be able to identify which function call is meant for which facet.
  • A Diamond needs to rely on a robust storage mechanism which lets Facets have access to private and shared state variables.
  • A Diamond provides the ability to add, remove or replace functions (upgrades) corresponding to a Facet.
  • A Diamond needs to keep track of all such upgrades in the past.

Key Benefits

The EIP-2535 Standard was designed to address some of the existing challenges around smart contract engineering through the following key benefits.

  • Unlimited Contract Size: A diamond does not have a max contract size and you can arbitrarily add as many facets/functions to it as needed, keeping them all together at a single address.
  • Upgradeability: Diamonds can be upgraded to add/replace/remove functionality. Because diamonds have no max contract size, there is no limit to the amount of functionality that can be added to diamonds over time.
  • Modular Code: The entire system is broken down into smaller, modular facets. Each facet is essentially just a module plugged into the overarching Diamond.
  • One Address: One fixed address denoting a Diamond can be used to support multiple facets and functionality.
  • Facet Reusability: The same facet can be plugged into many different and disparate Diamonds without any issues.

Source

The Standard Specification

Selector to Facet Mapping

Since a Diamond is connected to multiple facets, it must know which facet to execute for a given transaction. Add to this the fact that each function inside an EVM contract can be uniquely identified with a bytes4 value, a Diamond must internally store a bytes4 => address mapping, where address represents a particular Facet address on the chain.

Here’s a snippet from an actual Diamond implementation:

struct FacetAddressAndPosition {
    address facetAddress;
    uint96 functionSelectorPosition; // position in facetFunctionSelectors.functionSelectors array
}

//..........

struct DiamondStorage {
    // maps function selector to the facet address and
    // the position of the selector in the facetFunctionSelectors.selectors array
    mapping(bytes4 => FacetAddressAndPosition) selectorToFacetAndPosition;

		//..........
}

And here’s a nifty diagram to explain the mapping visually.

Source

The Fallback Function

Each Diamond must define a fallback function, which is a special function in Solidity, described by the docs as:

The fallback function is executed on a call to the contract if none of the other functions match the given function signature, or if no data was supplied at all.

Since almost all functions are, lexically speaking, defined on one of the Facets, any function call made on a Diamond will invoke the fallback function. This is because the function signature provided in the transaction data (a bytes4 value) will not match any of the functions on the Diamond.

All that’s left is for the fallback function to utilize the Selector to Facet Mapping to figure out the Facet address and employ the delegatecall opcode on this address.

Here’s some pseudocode from the actual EIP document.

// Find facet for function that is called and execute the
// function if a facet is found and return any value.
fallback() external payable {
  // get facet from function selector
  address facet = selectorTofacet[msg.sig];
  require(facet != address(0));
  // Execute external function from facet using delegatecall and return any value.
  assembly {
    // copy function selector and any arguments
    calldatacopy(0, 0, calldatasize())
    // execute function call using the facet
    let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
    // get any return value
    returndatacopy(0, 0, returndatasize())
    // return any return value or error back to the caller
    switch result
      case 0 {revert(0, returndatasize())}
      default {return (0, returndatasize())}
  }
}

The IDiamondCut Interface

Cool, so we now understand how you can perform some potent EVM assembly magic to delegate calls from a Diamond to its Facets.

Next up: how do we add, remove or replace functions in the Diamond? And the word functions here can be used interchangeably with facets i.e., you add a facet by adding a facet’s function and you remove a facet by removing all its functions inside the Diamond.

This is where the IDiamondCut interface comes into the picture.

interface IDiamondCut {
    enum FacetCutAction {Add, Replace, Remove}
    // Add=0, Replace=1, Remove=2

    struct FacetCut {
        address facetAddress;
        FacetCutAction action;
        bytes4[] functionSelectors;
    }

    /// @notice Add/replace/remove any number of functions and optionally execute
    ///         a function with delegatecall
    /// @param _diamondCut Contains the facet addresses and function selectors
    /// @param _init The address of the contract or facet to execute _calldata
    /// @param _calldata A function call, including function selector and arguments
    ///                  _calldata is executed with delegatecall on _init
    function diamondCut(
        FacetCut[] calldata _diamondCut,
        address _init,
        bytes calldata _calldata
    ) external;

    event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);
}

Any Diamond must implement this interface and in doing so, yields a public function called diamondCut.

  • The FacetCut struct defines the structure for adding, removing, or replacing facets and their function selectors.
  • The FacetCutAction enum specifies the three actions available to construct a FacetCut instance.
  • _init and _calldata can be used to invoke an arbitrary transaction (using delegatecall of course).

The Diamond Storage

Since the state is only ever hosted by the Diamond itself and Facets only ever store the code, there must be a way for the Diamond to organize all the heterogeneous state variables needed by each Facet in a way that each Facet’s code will understand.

Enter “Diamond Storage”. It is a struct storage mechanism where all state is defined in separate libraries and each library looks something like this:

library LibStorageFacetOne {
    bytes32 constant STORAGE_FACET_ONE = keccak256("diamond.standard.storage.facet.one");

    struct StorageFacetOne {
        uint96 number;
    }

    function getNumber() internal view returns (uint96 memory) {
        return diamondStorage().greeting;
    }

    function setGreeting(uint96 memory _newNumber) internal {
        diamondStorage().number = _newNumber;
    }

    function diamondStorage() internal pure returns (StorageFacetOne storage ds) {
        bytes32 position = STORAGE_FACET_ONE;
        assembly {
            ds.slot := position
        }
    }
}

The important function here is diamondStorage which, when executed by the Diamond contract in its own context, will return a pointer at a specific data slot unique to LibStorageFacetOne owing to the unique hash represented by STORAGE_FACET_ONE.

Ergo, all facets can have their own data storage libraries to create private data at arbitrary and unique slots inside the Diamond.

Conclusion

The EIP-2535 Diamonds Standard introduces the concept of multi-faceted proxies for smart contracts, enabling modular code organization, functionality aggregation at a single address, reusability and upgradeability.

All logic is broken down into separate, modular chunks. Each chunk is deployed as a separate Facet (implementation contract) in its own right, but its functions are then registered with a Diamond (proxy contract). The latter then makes use of the delegatecall EVM opcode to convey all transactions to their respective facets while still holding on to all the state.