Architectural pattern for writing client-friendly one-to-many proxy contracts (aka 'dynamic contracts') in Solidity.
This repository implements ERC-7504: Dynamic Contracts [DRAFT]. This repository provides core interfaces and preset implementations that:
- Provide guardrails for writing dynamic contracts that can have functionality added, updated or removed over time.
- Enables scaling up contracts by eliminating the restriction of contract size limit altogether.
⚠️ ERC-7504 [DRAFT] is now published and open for feedback! You can read the EIP and provide your feedback at its ethereum-magicians discussion link.
forge install https://github.com/thirdweb-dev/dynamic-contracts
npm install @thirdweb-dev/dynamic-contracts
src
|
|-- core
| |- Router: "Minmal abstract contract implementation of EIP-7504 Router."
| |- RouterPayable: "A Router with `receive` as a fixed function."
|
|-- presets
| |-- ExtensionManager: "Defined storage layout and API for managing a router's extensions."
| |-- DefaultExtensionSet: "A static store of a set of extensions, initialized on deployment."
| |-- BaseRouter: "A Router with an ExtensionManager."
| |-- BaseRouterWithDefaults: "A BaseRouter initialized with extensions on deployment."
|
|-- interface: "Interfaces for core and preset contracts."
|-- example: "Example dynamic contracts built with presets."
|-- lib: "Storage layouts and helper libraries."
This repository is a forge project. (forge handbook)
Clone the repository:
git clone https://github.com/thirdweb-dev/dynamic-contracts.git
Install dependencies:
forge install
Compile contracts:
forge build
Run tests:
forge test
Generate documentation
forge doc --serve --port 4000
An “upgradeable smart contract” is actually two kinds of smart contracts considered together as one system:
- Proxy smart contract: The smart contract whose state/storage we’re concerned with.
- Implementation smart contract: A stateless smart contract that defines the logic for how the proxy smart contract’s state can be mutated.
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).
ERC-7504 introduces a Router
smart contract.
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.
The simplest way to write a Router
contract is to extend the preset BaseRouter
available in this repository.
import "lib/dynamic-contracts/src/presets/BaseRouter.sol";
The BaseRouter
contract comes with an API to add/replace/remove extensions from the contract. It is an abstract contract, and expects its consumer to implement the _isAuthorizedCallToUpgrade
function, which specifies the conditions under which Extensions
can be added, replaced or removed. The rest of the implementation is generic and usable for all purposes.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@thirdweb-dev/dynamic-contracts/src/presets/BaseRouter.sol";
/// Example usage of `BaseRouter`, for demonstration only
contract SimpleRouter is BaseRouter {
address public deployer;
constructor() {
deployer = msg.sender;
}
/// @dev Returns whether all relevant permission checks are met before any upgrade.
function isAuthorizedCallToUpgrade() internal view virtual override returns (bool) {
return msg.sender == deployer;
}
}
The main decision as a Router
contract author is to decide the permission model to add/replace/remove extensions. This repository offers some examples of a few possible permission models:
-
This is a preset you can use to create static contracts that cannot be updated or get new functionality. This still allows you to create modular contracts that go beyond the contract size limit, but guarantees that the original functionality cannot be altered. With this model, you would pass all the Extensions for this contract at construction time, and guarantee that the functionality is immutable.
-
This a is a preset that allows the contract owner to add / replace / remove extensions. The contract owner can be changed. This is a very basic permission model, but enough for some use cases. You can expand on this and use a permission based model instead for example.
-
This is a preset that allows the owner to change extensions if they are defined on a given registry contract. This is meant to demonstrate how a protocol ecosystem could constrain extensions to known, audited contracts, for instance. The registry and router upgrade models are of course too basic for production as written.
An Extension
contract is written like any other smart contract, except that its state must be defined using a struct
within a library
and at a well defined storage location. This storage technique is known as storage structs.
Example: ExtensionManagerStorage
defines the storage layout for the ExtensionManager
contract.
// SPDX-License-Identifier: MIT
// @author: thirdweb (https://github.com/thirdweb-dev/dynamic-contracts)
pragma solidity ^0.8.0;
import "./StringSet.sol";
import "../interface/IExtension.sol";
library ExtensionManagerStorage {
/// @custom:storage-location erc7201:extension.manager.storage
bytes32 public constant EXTENSION_MANAGER_STORAGE_POSITION = keccak256(abi.encode(uint256(keccak256("extension.manager.storage")) - 1));
struct Data {
/// @dev Set of names of all extensions stored.
StringSet.Set extensionNames;
/// @dev Mapping from extension name => `Extension` i.e. extension metadata and functions.
mapping(string => IExtension.Extension) extensions;
/// @dev Mapping from function selector => metadata of the extension the function belongs to.
mapping(bytes4 => IExtension.ExtensionMetadata) extensionMetadata;
}
function data() internal pure returns (Data storage data_) {
bytes32 position = EXTENSION_MANAGER_STORAGE_POSITION;
assembly {
data_.slot := position
}
}
}
Each Extension
of a router must occupy a unique, unused storage location. This is important to ensure that state updates defined in one Extension
doesn't conflict with the state updates defined in another Extension
, leading to corrupted state.
By itself, the core Router
contract does not specify how to store or fetch appropriate implementation addresses for incoming function calls.
While the Router pattern allows to point to a different contract for each function, in practice functions are usually groupped by functionality related to a shared state (a read and a set function for example).
To make the pattern more practical, we created a generic BaseRouter
contract that makes it easy to have logical group of functions plugged in and out of it, each group of functions being implemented in a separate implementation contract. We refer to each such implementation contract as an extension.
BaseRouter
maintains a function_signature
→ implementation
mapping, and provides an API for updating that mapping. By updating the values stored in this map, functionality can be added to, removed from or updated in the smart contract.
Deploying a contract in the router pattern looks a little different from deploying a regular contract.
-
Deploy all your
Extension
contracts first. You only need to do this once perExtension
. DeployedExtensions
can be re-used by many differentRouter
contracts. -
Deploy your
Router
contract that implementsBaseRouter
. -
Add extensions to youe router via the API available in
BaseRouter
. (Alternatively, you can useBaseRouterDefaults
which can be initialized with a set of extensions on deployment.)
By itself, the core Router
contract does not specify how to store or fetch appropriate implementation addresses for incoming function calls.
While the Router pattern allows to point to a different contract for each function, in practice functions are usually groupped by functionality related to a shared state (a read and a set function for example).
To make the pattern more practical, we created a generic BaseRouter
contract that makes it easy to have logical group of functions plugged in and out of it, each group of functions being implemented in a separate implementation contract. We refer to each such implementation contract as an extension.
BaseRouter
maintains a function_signature
→ implementation
mapping, and provides an API for updating that mapping. By updating the values stored in this map, functionality can be added to, removed from or updated in the smart contract.
When splitting logic between multiple extensions in a Router
, one might want to access data from one Extension
to another.
A simple way to do this is by casting the current contract address as the Extension
(ideally its interface) we're trying to call. This works from both a Router
or any of its extensions.
Here's an example of accessing a IPermission
extension from another one:
modifier onlyAdmin(address _asset) {
/// we access our IPermission extension by casting our own address
IPermissions(address(this)).hasAdminRole(msg.sender);
}
Note that if we don't have an IPermission
extension added to our Router
, this method will revert.
Just like any upgradeable contract, there are limitations on how the data structure of the updated contract is modified. While the logic of a function can be updated safely, changing the data structure of a contract requires careful consideration.
A good rule of thumb to follow is:
- It is safe to append new fields to an existing data structure
- It is not safe to update the type or order of existing structs; deprecate and add new ones instead.
Refer to this article for more information.
You can generate and view the full API reference for all contracts, interfaces and libraries in the repository by running the repository locally and running:
forge doc --serve --port 4000
import "@thirdweb-dev/dynamic-contracts/src/core/Router.sol";
The Router
smart contract implements the ERC-7504 Router
interface.
For any given function call made to the Router contract that reaches the fallback function, the contract performs a delegateCall on the address returned by getImplementationForFunction(msg.sig)
.
This is an abstract contract that expects you to override and implement the following functions:
getImplementationForFunction
function getImplementationForFunction(bytes4 _functionSelector) public view virtual returns (address implementation);
delegateCalls the appropriate implementation address for the given incoming function call.
The implementation address to delegateCall MUST be retrieved from calling getImplementationForFunction
with the
incoming call's function selector.
fallback() external payable virtual;
getImplementationForFunction(msg.sig) == address(0)
delegateCalls an implementation
smart contract.
function _delegate(address implementation) internal virtual;
Returns the implementation address to delegateCall for the given function selector.
function getImplementationForFunction(bytes4 _functionSelector) public view virtual returns (address implementation);
Parameters
Name | Type | Description |
---|---|---|
_functionSelector |
bytes4 |
The function selector to get the implementation address for. |
Returns
Name | Type | Description |
---|---|---|
implementation |
address |
The implementation address to delegateCall for the given function selector. |
import "@thirdweb-dev/dynamic-contracts/src/presets/ExtensionManager.sol";
The ExtensionManager
contract provides a defined storage layout and API for managing and fetching a router's extensions. This contract implements the ERC-7504 RouterState
interface.
The contract's storage layout is defined in src/lib/ExtensionManagerStorage
:
struct Data {
StringSet.Set extensionNames;
mapping(string => IExtension.Extension) extensions;
mapping(bytes4 => IExtension.ExtensionMetadata) extensionMetadata;
}
The following are some helpful invariant properties of ExtensionManager
:
-
Each extension has a non-empty, unique name which is stored in
extensionNames
. -
Each extension's metadata specifies a non-zero-address implementation.
-
A function
fn
has a non-empty metadata i.e.extensionMetadata[fn]
value if and only if it is a part of some extensionExt
such that:extensionNames
containsExt.metadata.name
extensions[Ext.metadata.name].functions
includesfn
.
This contract is meant to be used along with a Router contract, where an upgrade to the Router means updating the storage of ExtensionManager
. For example, the preset contract BaseRouter
inherits Router
and ExtensionManager
and overrides the getImplementationForFunction
function as follows:
function getImplementationForFunction(bytes4 _functionSelector) public view virtual override returns (address) {
return getMetadataForFunction(_functionSelector).implementation;
}
This contract is an abstract contract that expects you to override and implement the following functions:
isAuthorizedCallToUpgrade
function isAuthorizedCallToUpgrade() internal view virtual returns (bool);
Checks that a call to any external function is authorized.
modifier onlyAuthorizedCall();
!_isAuthorizedCallToUpgrade()
Returns all extensions of the Router.
function getAllExtensions() external view virtual override returns (Extension[] memory allExtensions);
Returns
Name | Type | Description |
---|---|---|
allExtensions |
Extension[] |
An array of all extensions. |
Returns the extension metadata for a given function.
function getMetadataForFunction(bytes4 functionSelector) public view virtual returns (ExtensionMetadata memory);
Parameters
Name | Type | Description |
---|---|---|
functionSelector |
bytes4 |
The function selector to get the extension metadata for. |
Returns
Name | Type | Description |
---|---|---|
<none> |
ExtensionMetadata |
metadata The extension metadata for a given function. |
Returns the extension metadata and functions for a given extension.
function getExtension(string memory extensionName) public view virtual returns (Extension memory);
Parameters
Name | Type | Description |
---|---|---|
extensionName |
string |
The name of the extension to get the metadata and functions for. |
Returns
Name | Type | Description |
---|---|---|
<none> |
Extension |
The extension metadata and functions for a given extension. |
Add a new extension to the router.
function addExtension(Extension memory _extension) external onlyAuthorizedCall;
Parameters
Name | Type | Description |
---|---|---|
_extension |
Extension |
The extension to add. |
- Extension name is empty.
- Extension name is already used.
- Extension implementation is zero address.
- Selector and signature mismatch for some function in the extension.
- Some function in the extension is already a part of another extension.
Fully replace an existing extension of the router.
The extension with name extension.name
is the extension being replaced.
function replaceExtension(Extension memory _extension) external onlyAuthorizedCall;
Parameters
Name | Type | Description |
---|---|---|
_extension |
Extension |
The extension to replace or overwrite. |
- Extension being replaced does not exist.
- Provided extension's implementation is zero address.
- Selector and signature mismatch for some function in the provided extension.
- Some function in the provided extension is already a part of another extension.
Remove an existing extension from the router.
function removeExtension(string memory _extensionName) external onlyAuthorizedCall;
Parameters
Name | Type | Description |
---|---|---|
_extensionName |
string |
The name of the extension to remove. |
- Extension being removed does not exist.
Enables a single function in an existing extension.
Makes the given function callable on the router.
function enableFunctionInExtension(string memory _extensionName, ExtensionFunction memory _function)
external
onlyAuthorizedCall;
Parameters
Name | Type | Description |
---|---|---|
_extensionName |
string |
The name of the extension to which extFunction belongs. |
_function |
ExtensionFunction |
The function to enable. |
- Provided extension does not exist.
- Selector and signature mismatch for some function in the provided extension.
- Provided function is already a part of another extension.
Disables a single function in an Extension.
function disableFunctionInExtension(string memory _extensionName, bytes4 _functionSelector)
external
onlyAuthorizedCall;
Parameters
Name | Type | Description |
---|---|---|
_extensionName |
string |
The name of the extension to which the function of functionSelector belongs. |
_functionSelector |
bytes4 |
The function to disable. |
- Provided extension does not exist.
- Provided function is not part of provided extension.
Returns the Extension for a given name.
function _getExtension(string memory _extensionName) internal view returns (Extension memory);
Sets the ExtensionMetadata for a given extension.
function _setMetadataForExtension(string memory _extensionName, ExtensionMetadata memory _metadata) internal;
Deletes the ExtensionMetadata for a given extension.
function _deleteMetadataForExtension(string memory _extensionName) internal;
Sets the ExtensionMetadata for a given function.
function _setMetadataForFunction(bytes4 _functionSelector, ExtensionMetadata memory _metadata) internal;
Deletes the ExtensionMetadata for a given function.
function _deleteMetadataForFunction(bytes4 _functionSelector) internal;
Enables a function in an Extension.
function _enableFunctionInExtension(string memory _extensionName, ExtensionFunction memory _extFunction)
internal
virtual;
Note: bytes4(0)
is the function selector for the receive
function.
So, we maintain a special fn selector-signature mismatch check for the receive
function.
Disables a given function in an Extension.
function _disableFunctionInExtension(string memory _extensionName, bytes4 _functionSelector) internal;
Removes all functions from an Extension.
function _removeAllFunctionsFromExtension(string memory _extensionName) internal;
Returns whether a new extension can be added in the given execution context.
function _canAddExtension(Extension memory _extension) internal virtual returns (bool);
Returns whether an extension can be replaced in the given execution context.
function _canReplaceExtension(Extension memory _extension) internal virtual returns (bool);
Returns whether an extension can be removed in the given execution context.
function _canRemoveExtension(string memory _extensionName) internal virtual returns (bool);
Returns whether a function can be enabled in an extension in the given execution context.
function _canEnableFunctionInExtension(string memory _extensionName, ExtensionFunction memory)
internal
view
virtual
returns (bool);
Returns whether a function can be disabled in an extension in the given execution context.
function _canDisableFunctionInExtension(string memory _extensionName, bytes4 _functionSelector)
internal
view
virtual
returns (bool);
Returns the ExtensionManager storage.
function _extensionManagerStorage() internal pure returns (ExtensionManagerStorage.Data storage data);
To override; returns whether all relevant permission and other checks are met before any upgrade.
function isAuthorizedCallToUpgrade() internal view virtual returns (bool);
import "@thirdweb-dev/dynamic-contracts/src/presets/BaseRouter"
BaseRouter
inherits Router
and ExtensionManager
. It overrides the Router.getImplementationForFunction
function to use the extensions stored in the ExtensionManager
contract's storage system.
This contract is an abstract contract that expects you to override and implement the following functions:
isAuthorizedCallToUpgrade
function isAuthorizedCallToUpgrade() internal view virtual returns (bool);
Returns the implementation address to delegateCall for the given function selector.
function getImplementationForFunction(bytes4 _functionSelector) public view virtual override returns (address);
Parameters
Name | Type | Description |
---|---|---|
_functionSelector |
bytes4 |
The function selector to get the implementation address for. |
Returns
Name | Type | Description |
---|---|---|
<none> |
address |
implementation The implementation address to delegateCall for the given function selector. |
The best, most open way to give feedback/suggestions for the router pattern is to open a github issue, or comment in the ERC-7504 ethereum-magicians discussion.
Additionally, since thirdweb will be maintaining this repository, you can reach out to us at [email protected] or join our discord.