EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-7771: Router Proxy

A Simple Proxies Architecture with Multiple Hardcoded Implementations

Authors
Created2024-09-19
Discussion Linkhttps://ethereum-magicians.org/t/erc-7771-router-proxy/21137

Markdown

https://raw.githubusercontent.com/eternauta1337/ER...

EIP-GPT summary

Contents
AbstractMotivationSpecificationExample Router CodeRouter Code GenerationUpgradeabilityFunction HintingRationaleOverviewRouter ImplementationUpgradeabilityExplicit System CompositionSuspendable UpgradeabilityStorage NamespacesMixinsModule CommunicationFull System ImplementationBackwards CompatibilityReference ImplementationRouter Code GenerationSecurity ConsiderationsStorage ValidationRouter IntegrityProxy BrickingCopyright

Abstract

The Router Proxy introduces a streamlined approach to managing multiple implementations behind a single proxy, similar to the Diamond Proxy Standard ERC-2535. Unlike the latter, this method hardcodes module addresses within the proxy’s implementation contract, offering a simpler, more explicit, and gas-efficient mechanism. This design reduces complexity, making it easier to reason about and improving overall efficiency while retaining the flexibility to manage multiple modules.

Motivation

As Ethereum's application layer continues to mature, the complexity of smart contracts required for these applications has increased. Given the 24KB size limitation for a single smart contract, developers often need to compose applications using multiple interconnected contracts. This necessitates the use of an onchain registry to manage the relationships and interactions between these contracts. Typically, one contract within the application must know how to interact with another, querying the registry for specific addresses. Similarly, when a component of the system receives an interaction, it must verify with the registry whether the caller is an authorized part of the application. Consequently, non-trivial Ethereum applications require these "connecting wires," which are cumbersome to deploy, complex to code, inefficient at runtime, and, most importantly, prone to human error and common attack vectors due to the lack of standardized solutions.

The Diamond Proxy Standard addresses some of these challenges by allowing a single proxy to manage multiple implementations. This enables developers to construct multi-contract systems under a unified standard while also facilitating upgradeability. However, the Diamond Proxy Standard maintains a registry of the system's modules in storage, requiring each routing operation to read from storage and necessitating inspection queries to understand the system's configuration, which increments gas costs. This approach also obscures the system's structure, as the configuration is not directly visible in the code but must be queried dynamically, adding complexity and reducing transparency.

The Router Proxy Standard we propose retains the advantages of the Diamond Proxy while simplifying the system's architecture. By hardcoding the configuration of the system within the proxy's implementation contract, we make the system's structure explicit in the code, allowing for immediate inspection of its configuration. This approach not only enhances transparency but also improves gas efficiency in internal communications and simplifies the configuration process for system administrators.

Specification

The router proxy is a smart contract with no declared interface other than a fallback function, which maps dynamic incoming function calls to one of its many implementation modules, without the use of SLOAD. 1

The router MUST revert with UnknownSelector(bytes4 sig) if the incoming function selector is not implemented by any of its modules.

Function selectors MUST be unique across all modules.

Example Router Code

contract Router {
		error UnknownSelector(bytes4 sel);

		// @module OwnerModule
		address private constant _OWNER_MODULE = 0x5FbDB2315678afecb367f032d93F642f64180aa3;
		// @module SnxTokenModule
		address private constant _SNXTOKEN_MODULE = 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512;
		// @module SynthsModule
		address private constant _SYNTHS_MODULE = 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0;
		// @module UpgradeModule
		address private constant _UPGRADE_MODULE = 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9;

    fallback() external payable {
        _forward();
    }

    receive() external payable {
        _forward();
    }

    function _forward() internal {
        bytes4 sig4 = msg.sig;
				address implementation;

				// Yul lookup table: selector => implementation address
				assembly {
				    let sig32 := shr(224, sig4)

				    function findImplementation(sig) -> result {
				        if lt(sig,0x81d49b75) {
				            if lt(sig,0x53a47bb7) {
				                switch sig
				                case 0x0f36a2de { result := _SYNTHS_MODULE }
				                case 0x1627540c { result := _OWNER_MODULE }
				                case 0x2d6b3a6b { result := _SYNTHS_MODULE }
				                case 0x35eb2824 { result := _OWNER_MODULE }
				                case 0x3659cfe6 { result := _UPGRADE_MODULE }
				                case 0x51456061 { result := _SYNTHS_MODULE }
				                leave
				            }
				            switch sig
				            case 0x53a47bb7 { result := _OWNER_MODULE }
				            case 0x57d7995a { result := _SYNTHS_MODULE }
				            case 0x624bd96d { result := _OWNER_MODULE }
				            case 0x7104fba5 { result := _SYNTHS_MODULE }
				            case 0x718fe928 { result := _OWNER_MODULE }
				            case 0x79ba5097 { result := _OWNER_MODULE }
				        }
				        if lt(sig,0xd1b6c504) {
				            switch sig
				            case 0x81d49b75 { result := _SNXTOKEN_MODULE }
				            case 0x8da5cb5b { result := _OWNER_MODULE }
				            case 0xa44896e7 { result := _SYNTHS_MODULE }
				            case 0xaaf10f42 { result := _UPGRADE_MODULE }
				            case 0xc6253216 { result := _SYNTHS_MODULE }
				            case 0xc7f62cda { result := _UPGRADE_MODULE }
				            leave
				        }
				        switch sig
				        case 0xd1b6c504 { result := _SNXTOKEN_MODULE }
				        case 0xdaac1198 { result := _SNXTOKEN_MODULE }
				        case 0xe040da24 { result := _SNXTOKEN_MODULE }
				        case 0xf4c4dc31 { result := _SYNTHS_MODULE }
				        case 0xf5d6f068 { result := _SNXTOKEN_MODULE }
				        case 0xfbc6aa6f { result := _SYNTHS_MODULE }
				        leave
				    }

				    implementation := findImplementation(sig32)
				}

				if (implementation == address(0)) {
				    revert UnknownSelector(sig4);
				}

        // Delegatecall to the implementation contract
        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())
            }
        }
    }
}

Router Code Generation

Router code SHOULD NOT be written manually, and generated by automated tooling instead. Tooling MUST ensure unique function selectors across all modules and that there are no storage collisions between any of its modules.

The binary search algorithm used in the lookup table SHOULD order function selectors numerically, and cluster selectors by an optimal number of 5-10 selectors. This ensures that the routing gas cost of calling any selector in the system is consistent.

Upgradeability

A router does not inherently make a system upgradeable.

However, a router can be used in an upgradeable system by using it as the implementation of a single system proxy. Such a proxy does not need admin upgradeability functions, since upgradeability can be delegated to an UpgradeModule that defines upgrade functionality.

Function Hinting

Interfaces can be automatically generated to interact with protocols employing the router proxy by scanning the router’s code for @module tokens, which provide a module name and an implementation address.

Rationale

Overview

In a regular proxy, an incoming call to function b() is forwarded using DELEGATECALL to the proxy’s current implementation. 2

The router proxy is similar to a regular proxy, in that it receives incoming calls and forwards them to an implementation, the only difference being that a router may forward the call one of many —simultaneous— implementation contracts. It decides which implementation to forward to using a hardcoded lookup table that maps function selectors to their corresponding implementation contracts, without the need of using storage.

3

In the example above, the lookup table is used to determine that the selector for the function b() is implemented in the address of ModuleB.

This enables the router to overcome the 24KB contract size limit, since this limitation is now only relevant within each module, thus enabling the router to practically “merge” all the modules’ code.

The only caveat is that function selectors must be unique in all modules. I.e. ModuleC may not define a function b() if ModuleB also defines b().

Let’s continue, by looking at how the hardcoded lookup table is implemented.

Router Implementation

First of all, the router must implement a forwarding mechanism that uses DELEGATECALL, such as:

contract Router {
    fallback() external {
        _forward();
    }

    receive() external {
        _forward();
    }

    function _forward() internal {
        address implementation;
        // Lookup table code goes here...
        // Maps incoming function selector => implementation contract

        // Delegatecall to the implementation contract
        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())
            }
        }
    }
}

Given the table mapping function selectors to implementation addresses below,

FunctionSelectorImplementation Address
a()0xb67e3d430x5FbDB2315678afecb367f032d93F642f64180aa3
b()0x3f4ba83a0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
c()0x26121ff00x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0

A trivial Solidity implementation would look like

error UnknownSelector(bytes4 selector);

bytes4 selector = msg.sig;
address implementation;

if (selector == 0xb67e3d43) { implementation = 0x5FbDB2315678afecb367f032d93F642f64180aa3; }
else if (selector == 0x3f4ba83a) { implementation = 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512; }
else if (selector == 0x26121ff0) { implementation = 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0; }
else revert UnknownSelector(selector);

However, much better performance can be achieved by using Yul directly,

error UnknownSelector(bytes4 sel);

bytes4 sig4 = msg.sig;
address implementation;

assembly {
    let sig32 := shr(224, sig4)

    function findImplementation(sig) -> result {
        switch sig
        case 0xb67e3d43 { result := 0x5FbDB2315678afecb367f032d93F642f64180aa3 }
        case 0x3f4ba83a { result := 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 }
        case 0x26121ff0 { result := 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 }
        leave
    }

    implementation := findImplementation(sig32)
}

An additional optimization used is binary search by ordering selectors numerically, and jumping directly to the cluster of selectors that contains msg.sig.

This has the added benefit of yielding similar routing gas usage for all selectors in the system, which is better illustrated with a system with multiple modules, containing many selectors.

error UnknownSelector(bytes4 sel);

address private constant _OWNER_MODULE = 0x5FbDB2315678afecb367f032d93F642f64180aa3;
address private constant _SNXTOKEN_MODULE = 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512;
address private constant _SYNTHS_MODULE = 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0;
address private constant _UPGRADE_MODULE = 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9;

// Lookup table: Function selector => implementation contract
bytes4 sig4 = msg.sig;
address implementation;

assembly {
    let sig32 := shr(224, sig4)

    function findImplementation(sig) -> result {
        if lt(sig,0x81d49b75) {
            if lt(sig,0x53a47bb7) {
                switch sig
                case 0x0f36a2de { result := _SYNTHS_MODULE }
                case 0x1627540c { result := _OWNER_MODULE }
                case 0x2d6b3a6b { result := _SYNTHS_MODULE }
                case 0x35eb2824 { result := _OWNER_MODULE }
                case 0x3659cfe6 { result := _UPGRADE_MODULE }
                case 0x51456061 { result := _SYNTHS_MODULE }
                leave
            }
            switch sig
            case 0x53a47bb7 { result := _OWNER_MODULE }
            case 0x57d7995a { result := _SYNTHS_MODULE }
            case 0x624bd96d { result := _OWNER_MODULE }
            case 0x7104fba5 { result := _SYNTHS_MODULE }
            case 0x718fe928 { result := _OWNER_MODULE }
            case 0x79ba5097 { result := _OWNER_MODULE }
        }
        if lt(sig,0xd1b6c504) {
            switch sig
            case 0x81d49b75 { result := _SNXTOKEN_MODULE }
            case 0x8da5cb5b { result := _OWNER_MODULE }
            case 0xa44896e7 { result := _SYNTHS_MODULE }
            case 0xaaf10f42 { result := _UPGRADE_MODULE }
            case 0xc6253216 { result := _SYNTHS_MODULE }
            case 0xc7f62cda { result := _UPGRADE_MODULE }
            leave
        }
        switch sig
        case 0xd1b6c504 { result := _SNXTOKEN_MODULE }
        case 0xdaac1198 { result := _SNXTOKEN_MODULE }
        case 0xe040da24 { result := _SNXTOKEN_MODULE }
        case 0xf4c4dc31 { result := _SYNTHS_MODULE }
        case 0xf5d6f068 { result := _SNXTOKEN_MODULE }
        case 0xfbc6aa6f { result := _SYNTHS_MODULE }
        leave
    }

    implementation := findImplementation(sig32)
}

if (implementation == address(0)) {
    revert UnknownSelector(sig4);
}

Upgradeability

An important thing to point out, is that router proxies are not necessarily upgradeable. Despite the router’s forwarding mechanism being practically identical to the forwarding mechanism used in upgradeable proxies, the former is used for routing instead of upgrading.

However, this doesn’t mean that upgradeability cannot be used with router proxies. In fact, it is very easy to do so by setting a router as the implementation of an upgradeable proxy, and forwarding the call twice.

4

The apparent inefficiency of forwarding the call twice is compensated by the router’s hardcoded lookup table efficiency and the use of UUPS proxies (Universal Upgradeable Proxy Standard).

UUPS proxies define upgradeability functions within the implementation instead of the proxy, thus removing the complexity and gas inefficiencies of transparent proxies. See OpenZeppelin’s "Transparent vs UUPS proxies" documentation for more information.

Admin upgradeability functions can be hosted in one of the system’s modules, such as UpgradeModue , with functions such as upgradeTo , which could perform advanced checks during an upgrade, such as checking if an incoming upgrade will itself be upgradeable. This is implemented in Synthetix v3's core-contracts UUPSImplementation.

Explicit System Composition

Anyone willing to look at the code of a particular system can use tools like Etherscan to analyse the entry point of such a system. However, systems often involve multiple proxies, interconnected by some sort of common address registry that ties the system together. Thus, understanding the composition of a system is often an arduous process.

Such architecture design is caused by the 24KB contract size limitation, and can be mitigated by more advanced architectures like the one described here, using the router proxy, or by other approaches such as the diamond proxy.

However, when inspecting a diamond proxy, the system will need to be queried dynamically, since its composition is defined in storage and not in code.

A system that implements its upgrades via a router becomes very explicit instead. Users can look at the proxy’s current implementation contract to immediately understand the composition of system without the need of dynamic inspection.

Suspendable Upgradeability

Given that an upgradeable system can define its admin upgradeability functions within an UpgradeModule, a system upgrade could potentially choose to not include such a module, thus effectively removing upgradeability altogether.

This would be an elegant way to support upgradeability until the system has reached a certain level of maturity, in which its administrators decide that upgradeability is no longer required.

Storage Namespaces

Since the router proxy does not use storage at all, storage collisions between the router and the modules are not a concern. However, collisions between the modules themselves is a problem that needs to be addressed.

Since the execution context is the router contract (or a proxy contract if the router is used as the implementation of an upgradeable system), modules share a system-wide single context, and thus common storage.

To illustrate, if modules ModuleA and ModuleB both declare a variable at slot 0 of storage, a collision occurs.

5

To resolve this issue, we propose developers to use unstructured storage everywhere. The diamond standard presents a very elegant solution to solve this problem called “storage namespaces”, which we here adhere to.

Storage namespaces are actually very simple, consisting of picking a “random” slot in storage to store a particular data structure of the application.

6

The following contract implements a storage namespace for storing information about the owner of the system, using such unstructured storage.

contract OwnerStorage {
    struct OwnerStore {
        address owner;
    }

    function _ownerStore() internal pure returns (OwnerStore storage store) {
        assembly {
            // Hardcode hash for better performance
            // store.slot := bytes32(uint(keccak256("my.protocol.owner")) - 1)
            store.slot := 0x215597bacd9c7e977dfc170f320074155de974be494579d2586e5b268fa3b629
        }
    }
}

Using storage namespaces allows for arbitrary data to be stored within namespace struct definitions, resolving the problem of storage collisions.

The only care to be taken with storage namespaces is that, when using upgradeability, the storage layout of the data structs must only be appended to to avoid storage collisions between different versions of a particular module. This can also be easily validated with tooling.

Such “append only” restriction is a common problem of any proxy in fact, and is not specific to the router proxy.

Mixins

We recommend storage namespaces to be accessed by mixins, instead of arbitrary contracts in the system. As such, mixins are contracts that “know how to use a storage namespace”.

For example, a mixin for the OwnerStorage example above, looks like this:

contract OwnerMixin is OwnerStorage {
    modifier onlyOwner() {
        require(msg.sender == _ownerStore().owner, "Only owner allowed");
        _;
    }

    function setOwner(address _newOwner) public onlyOwner {
        _ownerStore().owner = _newOwner;
    }

    function getOwner() public view returns (address) {
        return _ownerStore().owner;
    }
}

Allowing a module of the system to access this storage via the mixin, as so:

contract SomeModule is OwnerMixin {
    function doSomething() public onlyOwner {
        // ...
    }
}

Even though this may seem cumbersome —certainly much more complex than just declaring global variables in Solidity— it is easy to get used to, and allows for the construction of surprisingly complex systems, with an elegant architecture.

Having mixins be the intermediaries of storage, ensures that storage is always used in a secure way. A module defines its associated storage, and a mixin that specializes in managing this storage. Other modules that require reading or writing to this storage can safely do so by using the mixin as an intermediary.

Module Communication

A crucial matter of a modular system is how modules communicate with each other. In a traditional system, ModuleA would need to call a registry AddressResolver to get the address of ModuleB, and then call the module.

If the function in ModuleB has access restrictions, the latter module would then need to check with AddressResolver again, to verify if the calling module is an authorized module of the system. A simple communication between modules in this case results in 3 CALL operations, and is thus very inefficient and expensive.

Initially, one may think that router communications could be solved by having ModuleA cast itself as ModuleB to call the latter’s function, which would effectively resolve the need for an AddressResolver, since the caller would be the system itself.

contract ModuleA {
  function communicateWithModuleB() public {
    // Don't do this!
    uint value = ModuleB(address(this)).doSomething();
  }
}

However, this approach looses information of msg.sender, since ModuleB would receive ModuleA's address as the sender, instead of the user.

Also, this still uses 1 CALL operation, which can be avoided, as we’ll see below.

This msg.sender issue could be resolved by using DELEGATECALL instead of CALL,

contract ModuleA {
  function communicateWithModuleB() public {
    (bool success, bytes memory value)
       = address(this).delegatecall(ModuleB.doSomething.selector);
    // Decode value here...
  }
}

But this would not only be cumbersome, but unnecessary.

In fact, intermodular communication in a router proxy does not even need a single CALL. The final method we propose is using mixins.

contract ModuleA is MixinB {
    function doSomething() public {
        MixinB.doSomething();
    }
}

MixinB is a utility that knows how to operate on the storage namsepace StorageB, used by ModuleB. All that ModuleA needs to do to communicate with ModuleB, is extend its mixin representative. This approach is not only secure, but does not require even a single CALL or DELEGATECALL.

A system designed with this architecture delegates much of its logic to mixins, making modules be mostly interfaces. Such an architecture is proven to scale in a healthy manner, easily overcoming contract size limit constraints.

The main factor of such scalability is the clear separation of storage definitions, storage actuators (mixins), and user facing interfaces.

Full System Implementation

A complex system using the architecture described above can be found in Synthetix v3 code in Github.

Backwards Compatibility

An upgradeable system that does not use the router proxy, can begin to do so by simply setting a router as its single implementation, as long as the new router architecture respects the previous implementation’s storage layout.

Similarly, a system that uses a router proxy can stop doing so, for example if a more advanced way to merge contracts and overcome the 24KB contract size limitation is used, such as the Smart Contract Hub proposed by maxsam4, Solidity issue #11102. If such a hub exists in the future, the system’s main proxy implementation can simply be swapped for the hub. Again, the new hub must respect the previous implementation’s storage layout.

Reference Implementation

Router Code Generation

  1. Collect al system modules

    This can be done by placing all module smart contracts within a single folder, or by using some sort of nomenclature such as *Module.

  2. Perform storage validations

    Check for storage collisions between the module’s current storage definitions.

    If the system is upgradeable, check for invalid storage mutations within each module’s storage structs.

  3. Collect all function selectors

    Here’s where non-duplicate selectors must be guaranteed.

function _validateSelectors(selectors: FunctionSelector[]) {
  const repeated = new Set(selectors.map((s) => s.selector).filter(_onlyRepeated));

  if (!repeated.size) return;

  const list = selectors
    .filter((s) => repeated.has(s.selector))
    .map((s) => `  ${s.selector} // ${s.contractName}.${s.name}()`)
    .join('\n');

  throw new ContractValidationError(
    `The following contracts have repeated function selectors behind the same Router:\n${list}\n`
  );
}

Selectors must then be ordered numerically,

selectors.sort((a, b) => {
  return Number.parseInt(a.selector, 16) - Number.parseInt(b.selector, 16);
});
  1. Construct the binary lookup table

    The binary search in the lookup table can be implemented with a simple recursive function

function binarySplit(node: BinaryData) {
  if (node.selectors.length > maxSelectorsPerSwitchStatement) {
    const midIdx = Math.ceil(node.selectors.length / 2);

    const childA = binarySplit({
      selectors: node.selectors.splice(0, midIdx),
      children: [],
    });

    const childB = binarySplit({
      selectors: node.selectors.splice(-midIdx),
      children: [],
    });

    node.children.push(childA);
    node.children.push(childB);

    node.selectors = [];
  }

  return node;
}
  1. Render the router code

    This can be as simple as using a Mustache template to insert the module and selector data.

  return Mustache.render(template, {
    moduleName: routerName,
    modules: _renderModules(contracts),
    selectors: _renderSelectors(binaryData),
  });

With a template such as,

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

// --------------------------------------------------------------------------------
// --------------------------------------------------------------------------------
// GENERATED CODE - do not edit manually!!
// --------------------------------------------------------------------------------
// --------------------------------------------------------------------------------

contract {{{moduleName}}} {
    error UnknownSelector(bytes4 sel);

    {{{modules}}}
{{#receive}}    {{{receive}}}{{/receive}}
{{^receive}}{{/receive}}
    fallback() external {
        // Lookup table: Function selector => implementation contract
        bytes4 sig4 = msg.sig;
        address implementation;

        assembly {
            let sig32 := shr(224, sig4)

            function findImplementation(sig) -> result {
                {{{selectors}}}
            }

            implementation := findImplementation(sig32)
        }

        if (implementation == address(0)) {
            revert UnknownSelector(sig4);
        }

        // Delegatecall to the implementation contract
        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())
            }
        }
    }
}

Security Considerations

Storage Validation

The router proxy architecture defined here significantly simplifies development complexity in a non-trivial smart contract system, but introduces an important problem: storage collisions between system modules.

Additionally, if an upgradeable router is used, storage collisions within each system module must also be checked.

This is why we propose using unstructured storage in all modules of the system and append only storage mutations, as described above.

Fortunately, such validations are easy to implement programatically. Thus, developers can attain to a simple set of rules and delegate such validations to a given software.

Router Integrity

It is important that the tooling relied upon for router code generation is not malicious. The generated router code can be independently inspected and verified to confirm expected behavior.

Proxy Bricking

If the router is used in an upgradeable system, such as with any other system that uses UUPS proxies, special care must be taken to ensure that a system upgrade includes admin upgradeability functions. Failure to do so would effectively “brick” a system, removing its ability to upgrade again.

However, as described above, this could be done intentionally.

Copyright and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth