Proxy Pattern And Upgradability

Description of the Proxy Pattern architecture

General

Elektro’s smart contract architecture is designed using the Proxy Delegate pattern (aka “Router and Resolver pattern”). State and functionality are separated. All function calls are sent to a Router address instead of the actual underlying smart contract. This design enables upgradability and extensible functionality under a single address.

Each module of the smart contract architecture is composed of multiple contracts which follow the aforementioned design pattern. This design pattern consists of 3 core concepts:

  • Router (+ Delegator)

  • Resolver

  • Implementation Contract(-s)

  • Optional Storage Contract

A Router is a contract that delegatecalls into multiple implementation contracts. These delegatecalls are executed in the context of the router. All storage reads/writes happen in the storage of the router. Each router has a resolver contract which maps the function signature to the address of the implementation contract. This is done to facilitate upgradeability of the system and extensible functionality under one unchanged address.

Architecture & Contracts

Contracts

Router

The Router delegates method invocation calls to the concrete logic implementation. The function’s code is executed in the context of the router by using delegatecall. A delegatecall in Ethereum works as follows: The code of the callee is executed in the context of the caller, additionally the original msg parameters are preserved.

Note that all data is stored in the context/storage of the Router. And Router does NOT get upgraded/redeployed.

Existing Storage layout of the implementation contracts should NOT ever change and will corrupt the data upon the upgrade if changed!

New storage slots have to be added below the existing once (appended to the end)!

All implementation contracts called by the same router have the same storage layout, and, most commonly, one Storage contract outlining common storage for all implementation contracts (“sitting” at the Router address). All implementation contracts have to inherit this Storage contract to be able to work with it and have the same storage layout. The Router defined in Router.sol is not deployed directly but inherited by the individual Router contracts which are deployed.

Every call (besides internal functionality) goes to the fallback function which determines the address of the implementation contract based on the function identifier (call to Resolver) from the call, then delegatecalls this implementation contract found.

Resolver

Maps function signatures to the address of the implementation contracts. These entries can be updated by accounts bearing the governor role. All routers are connected to their respective resolvers. Resolver stores the first 4 bytes of keccak256 hash of a function signature and lookup() returns the address mapped with these 4 bytes.

Functions are registered in two ways:

  • register() (bulkRegister()) - for new functions and contracts

  • update() (bulkUpdate()) - for changed functions and updated contracts

Only accounts bearing Governor role can call these functions during the deploy/upgrade of the system!

Resolver contracts do NOT get upgraded/redeployed for modules existing on-chain! They are only updated through calling the above functions.

Implementation Contract(-s)

Each Proxy Module can have one or multiple implementation contracts.

In case of one contract, there's no difference between this implementation contract and any other regular Solidity contract.

In case of multiple contracts Storage has to be the same with absolutely no changes/discrepancies between the implementations within one Proxy Group (module).

Storage Contract

In case of multiple implementations or a large contract state (many storage vars) a separate Storage contract can be created to outline all storage (state) variable of the module.

All implementation contracts of one Proxy Module HAVE to inherit the same Storage contract and do not add to it within their own code!

Getters/Setters & Interfaces

In case of Storage contract present, interfaces have to be created for all public state variables in order to be available to read from Router bound calls. These interfaces will be bound to the Router's ABI and will allow backend to see and call all available functionality of the module.

If any storage functionality overhead is needed, Setters or Getters contracts can be created in order to provide specific logic needed.

Each implementation contract HAS to have it's own interface, and in case of multiple implementations, those interfaces should be inherited by the main "umbrella" interface which during deployment will serve as an ABI for the Router, providing access to the full external functionality of the module.

All the implementation functionality of the module is bound to the Router's address, so even if we have 15 implementation contracts, we will always only call Router of the module, which HAS to have all this functionality outlined in one Interface!

Flow

Functionality is strictly separated. If one part needs a functionality of another part it delegatecalls into the code of the other part. This pattern is wrapped into the delegate function of the Delegator. The below diagram depicts the Router Pattern:

Deployment & Initialization

  • All implementation contracts are deployed

  • Resolver contract is deployed and initialized with all function signatures and the addresses of the implementation contracts

  • Router is deployed and it's constructor initializes storage of the Proxy Module by calling init() function on the Implementation, this storage then becomes the storage of the Router

The individual parts of the system have their own router/resolver contracts. Each proxy group (functionality module) has one Router and one Resolver contract along with one or multiple implementation contracts.

initialized storage variable (boolean) is used in all proxy groups’ storages to signify that a proxy group (module) has been deployed and it’s initial storage (state) set by calling init functions on implementations. Each init() function of the implementation contract is checking if this variable is “false” (otherwise – revert) and sets it to “true” upon storage initialization. This is used to prevent additional calls to init() functions and resetting the storage after deployment (this can only be done by manual calls from Governor or Admin accounts to the respective setter functions for each slot).

Example

An example of this pattern is the FundLock. FundLock manages the funds, but has no functionality to transfer tokens. If FundLock has to release tokens it does so by using the inherited Delegator functionality which executes a delegatecall into the TokenWrapper to execute the code invoking the safe transfer of the funds. The below code snippet illustrates this structure.

FundLockRouter (as with any other specific Router) inherits all main Router functionality here and adds an initialization call for the storage. Initialization of the implementation contract FundLockAdmin and storage (FundLockStorage) happens at the construction of the Router, given it is going to maintain storage, where Resolver.lookup() returns an appropriate address for a hardcoded function signature of FundLockAdmin.init(). Arguments are assembled, encoded and passed through deletgatecall which triggers FundLockAdmin functionality to set all necessary storage variables.

Exceptions

The below contracts have to be called directly and are not upgradable. They are exceptions to the Proxy Delegate pattern use:

  • RoleManager

  • TokenWrapper

  • TokenValidator

Last updated