Modular Contracts design document.
Modular Contracts is a framework for writing highly composable smart contracts. These are smart contracts for which you can add, remove, upgrade or switch out the exact parts you want.
A modular contract is made up of two kinds of contracts:
- Core contract: the foundational API that can be customized by installing Extensions.
- Extension contract: implements a set of functionality that is enabled on a Core when it is installed.
Installing an Extension in a Core customizes the Core’s behaviour in two ways:
- New functions become callable on the Core contract (via its fallback function).
- Core contract’s fixed functions make callback function calls into the Extension.
As an example — a developer can write an ERC-721 NFT smart contract as a Core contract. An entire ecosystem of third-party developer built customizations can form around this one Core.
Various minting and burning mechanisms, token metadata formats, soulbound capabilities, etc. can all be implemented as independent Extension smart contracts which can be plugged into a developer’s ERC-721 Core contract.
This means — builders can now deploy this ERC-721 Core contract, and access a host of customizations they can use to evolve their NFT collection over time.
As seen in this example, the advantage of building a product with Modular Contracts is:
- Future Proof: a product has needs that evolve over time. Modular contracts can be updated to adapt to changing product requirements and new industry innovations as needed.
- Flexible: the Modular Contracts framework is compatible with all upgradeability and feature-related industry standards. This means modular contracts can be written to follow any of the popular EIPs and be structured as upgradeable or non-upgradeable contracts — all without losing out on its customizability.
- Highly Customizable: Modular Contracts have been developed to enjoy a vast library of opt-in customizations in which you can discover the right smart contract features for building out your use case.
This architecture standardizes how a router contract verifies that an implementation contract is safe and compatible as a call destination for a given set of functions.
The architecture outlines interfaces for router contracts and implementation contracts that let them communicate and agree over compatibility with each other, and interfaces for ERC-165 compliance by router contracts.
Router contracts (i.e. contracts with a potentially different call destination per function) have gained adoption for their quality of being future-proof and upgradeable in parts.
There are various different ways to write router or implementation contracts, which means using any given implementation contract as a call destination in any given router contract can lead to either contract not operating according to its specification.
The goal of this architecture is to make all router and implementation contracts interoperable by creating a method where both contracts communicate and agree over compatibility before a router sets some implementation contract as the call destination for a set of functions.
The ecosystem benefits from this standardization as
- developers can safely re-use any self or third-party developed features (implementation contracts) across many projects (router contracts).
- new feature innovations (implementation contracts) can explicitly break compatibility with older, already deployed projects (router contracts).
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: a smart contract with a potentially different call destination per function
- Implementation: a smart contract stored by a router contract as the call destination a given set of functions.
- Modular Core: a router contract written in the Modular Contract architecture and expresses compatibility with certain implementation contracts. Also referenced as “Core”.
- Modular Extension: an implementation contract written in the Modular Contract architecture and expresses compatibility with certain router contracts. Also referenced as “Extension”.
The ExtensionConfig
struct contains all information that a Core uses to check whether an Extension is compatible for installation.
ExtensionConfig
struct
FallbackFunction
struct
CallbackFunction
struct
A router contract MUST implement IModularCore
and ERC-165 interfaces to comply with the Modular Contract architecture.
The ERC165.supportsInterface
function MUST return true for all interfaces supported by the Core and the supported interfaces expressed in the ExtensionConfig of installed extensions.
Any given callback function in the ExtensionConfig of an installed Extension MUST be called by the Core during the function execution of some fixed function.
Any given fallback function in the ExtensionConfig of an installed Extension MUST be called by the Core via its fallback, when called with the given fallback function’s calldata.
We allow for a Core to be customized by Extension contracts in two different ways — callback functions and fallback functions.
Callback functions are function calls made to an Extension at some point during the execution of a fixed function. They allow injecting custom logic to run within a Core’s fixed functions. This means a Core can have a foundational API of fixed functions which can nevertheless enjoy customizations.
Fallback functions are functions that are callable on the Core as an entrypoint, whereon the Core calls an Extension from its fallback function with the calldata it receives. They allow additions to a Core’s foundational API of fixed functions.
All callback and fallback functions care called via performing a delegateCall on the Extension contract where the respective function is defined. This means that Extension contracts define functions that instruct the Core contract on how it should update its state.
This is to allow developers to only care about a core contract’s address as an entrypoint for calling any functions, and for the state making up the whole smart contract system to not be split across the Core and various Extension contracts, and instead, only be consolidated in the Core contract’s state.
An Extension is compatible to install in a Core if:
-
all of the Extension’s callback functions (specified in ExtensionConfig) are included in the Core’s supported callbacks (specified in IModularCore.getSupportedCallbackFunctions).
This is because we assume that an Extension only specifies a callback function in its ExtensionConfig when it expects a Core to call it.
-
the Core implements the required interface (if any) specified by the ExtensionConfig
It is optional for an ExtensionConfig to specify an interface that a Core must implement. However, some Extensions may only be sensible to install in particular Core contracts, and the ExtensionConfig.requiredInterfaceId field encodes this requirement.
Both IModularCore.getSupportedCallbackFunctions and IModularExtension.getExtensionConfig are pure functions, which means their return value does not change based on any storage.
For a given Extension, it is important for the Core’s stored representation of an ExtensionConfig to not go out of sync with the actual return value of IModularExtension.getExtensionConfig at any time, since this may lead to unintended consequences such as the Core calling functions on the Extension that no longer exist or be called on the Extension contract.
The FallbackFunction struct contains a uint256 permissions
field that allows expressing the permissions required by the msg.sender in the Core contract’s fallback to be authorized for calling the relevant function on the Extension contract.
This is important because a caller should be authorized for making the state updates to the Core contract that’ll result from a delegateCall to the relevant Extension contract function.
The CallbackFunction struct does not contain a similar permissions struct field.
This is because a callback function call is specified in the function body of a fixed function, and so, the authorization a caller is left to the Core contract itself since it is expected that the Core will perform authorization checks on callers in its fixed functions, wherever necessary.
https://github.com/thirdweb-dev/modular-contracts/blob/main/src/ModularCore.sol
For a Core to go “out of sync” with an installed Extension means that the extension config stored locally by the Core is different from the return value of the getExtensionConfig
function of the Extension.
Since the extension config of an Extension encodes the spec. that defines how the Extension contract is meant to be used when installed, a Core going out-of-sync with an installed Extension in this way is spec. breaking for both the Core and Extension contracts.
This scenario can occur when the return value of the getExtensionConfig
function changes after and while the Extension is installed in the Core. Since the getExtensionConfig
is a pure function, this is only possible when the installed Extension is a proxy contract whose underlying implementation can be upgraded, and hence, the return value of the pure function getExtensionConfig
can potentially change.
For this reason, we recommend not using already proxy contracts as Extensions to install, and rather, install Extensions that are non-proxy, implementation contracts.
An upgrade/patch to an installed Extension should be performed by first uninstalling the Extension, and then re-installing the Extension by providing the installExtension
function the relevant new implementation address.
thirdweb is excited to bring Modular Contracts to developers. The Modular Contract framework is actively being developed in the opensource thirdweb-dev/modular-contracts github repository, and is currently in audit.