EIP-7504: Dynamic Smart Contracts

A new Ethereum standard for client-friendly one-to-many proxy contracts.

EIP-7504: Dynamic Smart Contracts

Abstract


Building upgradeable contracts in the ‘proxy pattern’ — a proxy contract forwarding calls to an implementation contract — is now industry standard.

diagram showing a proxy contract that forwards calls to an implementation contract.

Standards like ERC-2535 Diamonds introduce the concept of a proxy contract using many implementation smart contracts, where the proxy selects one out of many implementation contracts to delegateCall.

This proposal standardizes how ‘proxy-with-many-implementations’ or ‘one-to-many’ proxy contracts can be written with client friendliness and adoption in mind. Concretely, this proposal standardizes how one-to-many proxy should expose their true ABI for straightforward contract interaction via clients.

Going forward, we refer to such one-to-many proxy contracts as dynamic contracts.

Motivation


This proposal does not introduce any novel technique of writing smart contracts. Dynamic contracts, or ‘one-to-many’ proxy contracts have been introduced before in prior EIP standards, or in custom implementations.

This proposal aims to propel the adoption of dynamic contracts by making them easy to grasp, and straightforward to interact with via clients.

The advantages of building dynamic contracts is well documented in ERC-2535. We re-state some of them here:

  • Contract code size limitation (EIP-170) is no longer a limitation in building feature-complete smart contracts.
  • Make per function level upgrades to a smart contract.
  • Re-use already deployed contracts as implementations

It is clear that this pattern of writing smart contracts has its advantages. The motivation for this proposal goes beyond these advantages.

1. Comprehensibility

As a piece of software, smart contracts have become public-facing backends. Discovering, comprehending or verifying smart contract code on block scanners is second nature to developers, and even users of high-level blockchain applications.

This proposal embraces this aspect of smart contracts, and introduces an easy to digest pattern for writing dynamic contracts, without excess terminology or complexity.

2. Client friendliness

The lifecycle for smart contracts includes someone building a client to interact with it. Building a client — graphical or programatic — to interact with a smart contract requires an ABI, and a well defined interpretation of the ABI to instruct the interaction.

This proposal focuses on client friendliness. Concretely, this means that it should be straightforward to build clients to interact with dynamic contracts.

Specification


The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Router

Every ERC-7504 compliant contract MUST implement the Router interface:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

/**
 *	@title ERC-7504 Dynamic Contracts.
 *	@dev Fallback function delegateCalls `getImplementationForFunction(msg.sig)` for a given incoming call.
 *	NOTE: The ERC-165 identifier for this interface is 0xce0b6013.
 */
interface Router {

	/**
	 *	@notice delegateCalls the appropriate implementation address for the given incoming function call.
	 *	@dev The implementation address to delegateCall MUST be retrieved from calling `getImplementationForFunction` with the
     *       incoming call's function selector.
	 */
	fallback() external payable;

    /// @dev Returns the implementation address to delegateCall for the given function selector.
    function getImplementationForFunction(bytes4 _functionSelector) external view returns (address);
}

The fallback function MUST call getImplementationForFunction with ‘msg.sig’ i.e. the first four bytes of the calldata, and perform a delegateCall on the return value.

The following is a reference implementation of Router:

// SPDX-License-Identifier: MIT
// @author: thirdweb (https://github.com/thirdweb-dev/dynamic-contracts)

pragma solidity ^0.8.0;

abstract contract DynamicContract is Router {
    /// @dev delegate calls the appropriate implementation smart contract for a given function.
    fallback() external payable virtual {

        address implementation = getImplementationForFunction(msg.sig);
        _delegate(implementation);
    }

    /// @dev delegateCalls an `implementation` smart contract.
    function _delegate(address implementation) internal virtual {
        assembly {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }

    /// @dev Unimplemented. Returns the implementation contract address for a given function selector.
    function getImplementationForFunction(bytes4 _functionSelector) public view virtual returns (address);
}

RouterState

Every ERC-7504 compliant contract MUST implement the RouterState interface:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

/**
 *	@title ERC-7504 Dynamic Contracts.
 *	NOTE: The ERC-165 identifier for this interface is 0x4a00cc48.
 */
interface RouterState /* is Router */ {
    /*///////////////////////////////////////////////////////////////
                                Structs
    //////////////////////////////////////////////////////////////*/

    /**
     *  @notice An extension's metadata.
     *
     *  @param name             The unique name of the extension.
     *  @param metadataURI      The URI where the metadata for the extension lives.
     *  @param implementation   The implementation smart contract address of the extension.
     */
    struct ExtensionMetadata {
        string name;
        string metadataURI;
        address implementation;
    }

    /**
     *  @notice An interface to describe an extension's function.
     *
     *  @param functionSelector    The 4 byte selector of the function.
     *  @param functionSignature   Function signature as a string. E.g. "transfer(address,address,uint256)"
     */
    struct ExtensionFunction {
        bytes4 functionSelector;
        string functionSignature;
    }

    /**
     *  @notice An interface to describe an extension.
     *
     *  @param metadata     The extension's metadata; it's name, metadata URI and implementation contract address.
     *  @param functions    The functions that belong to the extension.
     */
    struct Extension {
        ExtensionMetadata metadata;
        ExtensionFunction[] functions;
    }

    /*///////////////////////////////////////////////////////////////
                            View Functions
    //////////////////////////////////////////////////////////////*/

    /// @dev Returns all extensions of the Router.
    function getAllExtensions() external view returns (Extension[] memory allExtensions);
}

The return value of getAllExtensions includes all callable functions on the contract, grouped by the implementation contract where they are implemented.

For a given callable function on the contract, the return value of getImplementationForFunction for its function selector MUST match the implementation contract expressed for it in the return value of getAllExtensions.

Concepts


An “upgradeable smart contract” is actually two kinds of smart contracts considered together as one system:

  1. Proxy smart contract: The smart contract whose state/storage we’re concerned with.
  2. Implementation smart contract: A stateless smart contract that defines the logic for how the proxy smart contract’s state can be mutated.
diagram showing a proxy contract that always performs delegateCall on the same implementation contract.

The job of a proxy contract is to forward any calls it receives to the implementation contract via delegateCall. As a shorthand — a proxy contract stores state, and always asks an implementation contract how to mutate its state (upon receiving a call).

This proposal introduces a Router smart contract.

diagram showing a router contract that delegateCalls a specific implementation based on the function called.

Instead of always delegateCall-ing the same implementation contract, a Router delegateCalls a particular implementation contract (i.e. “Extension”) for the particular function call it receives.

A router stores a map from function selectors → to the implementation contract where the given function is implemented. “Upgrading a contract” now simply means updating what implementation contract a given function, or functions are mapped to.

upgrading a router contract means updating its function selector → to implementation contract map.

Rationale


Router

By ‘Router’, we mean a contract implementing the Router interface.

We call such a contract ‘router’ because its main, and only job is to route incoming function calls to the right implementation contract.

The fallback function is triggered on a call to a non fixed function (See “Fixed Functions" under the Rationale section). The fallback function uses the first four bytes of the calldata (msg.sig) along with the getImplementationForFunction function to determine which implementation contract it should perform a delegate call on.

A Router is a proxy contract.

A proxy contract is a contract that delegateCalls an implementation contract for instructions on how its own state should be mutated. This is what a Router does.

The common association with proxy contracts is of a minimal proxy contract that delegateCalls a single implementation contract.

diagram showing a proxy contract that forwards calls to an implementation contract.

A Router contract can be used with or without such a minimal proxy contract. That is, a Router contract can be deployed and used directly, without putting it behind a minimal proxy. Additionally, a Router contract can also be used by putting it behind a minimal proxy.

This proposal makes no recommendations in this regard. Either form of usage maintains the usefulness of the standard.

Fixed functions

A dynamic contract may include fixed functions.

By strict definition, ‘fixed functions’ on a dynamic contract are functions that, when called, do not trigger the fallback function.

Fixed functions are functions implemented in the top-level router contract itself, and not in any implementation contract that must be delegateCall-ed. Fixed functions cannot be upgraded, or removed.

This proposal requires 2 fixed functions in a dynamic contract: Router.getImplementationForFunction and RouterState.getAllExtensions.

This guarantees that the system by which the contract exposes its ABI does not change.

RouterState, Extensions and building an ABI

The RouterState interface is at the core of making dynamic contracts client friendly.

Building a client — graphical or programatic — to interact with a smart contract requires an ABI, and a well defined interpretation of the ABI to instruct the interaction.

Dynamic contracts pose two immediate challenges in this regard:

  1. The callable functions of a dynamic contract may change. This poses a challenge for clients, since they cannot always anticipate the same ABI to work for a dynamic contract.
  2. The callable functions of a dynamic contract are spread across many implementation contracts.

The RouterState interface creates a natural grouping that we call Extension. At any given point, a dynamic contract knows of a fixed set of extensions.

An Extension is an abstraction of an implementation contract used by a dynamic contract.

The Extension struct is designed to let the smart contract and its clients help each other. Concretely, the design aims to create room for coordination between smart contracts and clients.

  • Naming extensions allows both parties to create a coherent, conceptual grouping of what function(-ality) lives together in which implementation smart contract.
  • An extension’s metadata URI allows for specific coordination between smart contracts and clients about how to understand, interact with or render UI for a given extension of a router.
  • Storing both function selectors and function signatures for each function implemented by an extension allows for a precise, joint ABI to be built for a dynamic contract.

With this grouping of functions by Extensions, the RouterState.getAllExtensions function acts as a fixed function that returns a dynamic contract’s ABI.

The list of extensions returned by getAllExtensions corroborates with the return values of getImplementationForFunction. Thus, these two functions taken together creates a reliable source to fetch and build a dynamic contract’s ABI.

Function selector clashes

What happens when two separate implementation contracts used by the same dynamic contract implement the same function? (same function signature, even if the function bodies differ)

This proposal requires that the fallback function must always perform a delegateCall to the return value of getImplementationForFunction(msg.sig).

This requirement must be met even in the situation described above. This proposal does not require a specific way of handling the situation.

That said, it is not necessary for the Extension.functions list, for a given implementation contract, to be an exhaustive list of all callable functions of the implementation contract.

And so, in case two implementation contracts implement the same function, the Extension.functions list for one of the implementation contracts can simply omit the clashing function.

We expand further on function selector clashes in the security considerations section, under “Accidental Upgrades”.

Adapters

The focus of this proposal is comprehensibility and client friendliness for dynamic contracts.

Contracts written in standards such as ERC-2535 or ERC-6900 which also focus on dynamic contracts, or ‘one-to-any’ proxy contracts can support this standard.

For example, one can write an ‘adapter’ smart contract that implements the RouterState interface, which can then be added as a Facet to an ERC-2535 Diamond.

Backwards Compatibility


No backwards compatibility issues found.

Reference Implementation


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

/// NOTE: This is a naive reference implementation. It is not intended to be used in production.

import "./RouterState.sol"; // interface RouterState is Router { /* ... */}

contract DynamicContract is RouterState {

    string[] private names;
    mapping(string => Extension) private allExtensions;
    mapping(bytes4 => ExtensionMetadata) private extensionMetadataForFunction;

    constructor(Extension[] memory _extensions) {
        for (uint256 i = 0; i < _extensions.length; i++) {
            Extension memory extension = _extensions[i];

            names.push(extension.metadata.name);
            allExtensions[extension.metadata.name] = extension;

            for (uint256 j = 0; j < extension.functions.length; j++) {
                ExtensionFunction memory extFunction = extension.functions[j];
                extensionMetadataForFunction[extFunction.functionSelector] = extension.metadata;
            }
        }
    }

    fallback() external payable virtual {
    /// @dev delegate calls the appropriate implementation smart contract for a given function.
        address implementation = getImplementationForFunction(msg.sig);
        _delegate(implementation);
    }

    /// @dev delegateCalls an `implementation` smart contract.
    function _delegate(address implementation) internal virtual {
        assembly {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }

    /// @dev Returns the implementation contract address for a given function signature.
    function getImplementationForFunction(bytes4 _functionSelector) public view virtual returns (address) {}

    /// @dev Returns all extensions of the Router.
    function getAllExtensions() external view returns (Extension[] memory extensions) {
        extensions = new Extension[](names.length);
        for (uint256 i = 0; i < names.length; i++) {
            extensions[i] = allExtensions[names[i]];
        }
    }
}

Security Considerations


Accidental Upgrades

An 'upgrade’ to a dynamic contract means changing what implementation address a given function selector is mapped to.

Allowing for a direct update to / overwriting the implementation address a function is mapped to is discouraged since this may lead to unintentional upgrades.

This proposal recommends the following:

A dynamic contract SHOULD only allow mapping a function selector to an implementation address once / when the function selector is not mapped to a different implementation address (except the zero address).

Example:

Let’s say a ERC-721 NFT contract is a dynamic contract, and all of its callable ERC-721 functions are mapped to Extension_A.metadata.implementation.

If we are to upgrade the the approve function to be mapped to Extension_B, then the dynamic contract should only allow this upgrade once the approve function is no longer mapped to Extension_A.metadata.implementation.

Concretely, this means the approve function must first be mapped to the zero address in one call, and then mapped to Extension_B.metadata.implementation in a subsequent call. These calls can be a part of a multicall, and so, can be processed within the same transaction. However, it is encouraged to maintain such two separate calls to ensure that an upgrade is intentional.

In this example, it is not necessary to change how the rest of the ERC-721 functions are mapped to Extension_A.metadata.implementation.

Function implementation includes selfdestruct

When using a minimal proxy contract pointing to a Router as its single implementation contract, it is important for no extension function in the Router to include selfdestruct. A delegateCall to such an extension function would destroy the implementation contract i.e. the Router, and cause any proxies delegating to it to lose all of their functionality.

When using a Router directly (i.e. without putting it behind a minimal proxy contract), it is also important for no extension function in the Router to include selfdestruct. A delegateCall to such an extension function would destroy the Router contract.

Permissions

An 'upgrade’ to a dynamic contract means changing what implementation address a given function selector is mapped to.

This proposal makes no recommendations about how upgrades to a Router contract should be permissioned. Upgrades to a dynamic contract should be permissioned carefully, or purposefully.

Discussion


See the full proposal in the ERC-7504 Github repo:

GitHub - thirdweb-dev/dynamic-contracts: Architectural pattern for writing dynamic smart contracts in Solidity.
Architectural pattern for writing dynamic smart contracts in Solidity. - GitHub - thirdweb-dev/dynamic-contracts: Architectural pattern for writing dynamic smart contracts in Solidity.

And join the ERC-7504 Ethereum Magicians discussion:

ERC-7504: Dynamic Contracts
Hey all 👋 I’d love to introduce ERC-7504: Dynamic Contracts – client-friendly one-to-many proxy contracts. This proposal standardizes how ‘proxy-with-many-implementations’ or ‘one-to-many’ proxy contracts can be written with client friendliness and adoption in mind. The proposal is a draft, an…