Guide: write and deploy Modular Smart Contracts.

Learn how to write and deploy Modular Smart Contracts -- smart contracts for which you can add, remove, upgrade or switch out the exact parts you want.

Guide: write and deploy Modular Smart Contracts.

Modular Contracts are smart contracts for which you can add, remove, upgrade or switch out the exact parts you want.

In this guide, we’ll walk through how to:

  1. write your own Solidity modular contract,
  2. deploy it, and
  3. upgrade its functionality after deployment.

Background

A modular contract is made up of two kinds of contracts:

  1. Core contract: the foundational API that can be customized by installing Extensions.
  2. Extension contract: implements a set of functionality that is enabled on a Core when it is installed.

As an analogy, think of the robot, below. The torso is the Core to which you attach/detach Extensions.

Modular Contract analogy: a robot whose torso has sockets for attaching extensions.

The torso has a particular shape and exposes sockets at fixed places -- all of which determine how extensions will be attached to it. Similarly, a Core smart contract has a fixed API that determines what Extensions are compatible for installation.

The Extensions themselves e.g. a leg, arm, head, etc. give the robot (i.e. the modular contract as a whole) its various capabilities.

Installing an Extension in a Core customizes the Core’s behaviour in two ways:

  1. New functions become callable on the Core contract (via its fallback function).
  2. Core contract’s fixed functions make callback function calls into the Extension.
Transaction flow diagram of a Core and its installed Extension.

All calls made to an Extension contract (via the Core) are delegateCalls. So, to ensure that only “compatible” Extensions are able to update a Core’s state —

  • an Extension can enforce that a Core must implement one interface or a set of interface to be eligible for installing the Extension.
  • a Core can expose a list of supported callback functions and reject installing Extensions that implement an unsupported callback function.

You can read about the full technical design details of Modular Contracts in its design document.

Project Setup

In an empty directory, run:

forge init

This creates an empty Forge project.

Next, install the thirdweb-dev/modular-contracts repo as a dependency:

forge install https://github.com/thirdweb-dev/modular-contracts

We’ll build our modular contract using thirdweb’s Modular Contract framework. We’ll write a Core contract that is a ModularCore, and an Extension that is a ModularExtension.

Next, install the Solady repo as a dependency:

forge install https://github.com/Vectorized/solady

We’re going to write a a simple ERC-20 token modular contract, and we’ll inherit Solady’s ERC20 implementation so that we can focus only on making that ERC20 contract modular.

Create an ERC20Core.sol file in the /src directory and paste the following:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract ERC20Core {}

The Core contract will implement the ERC-20 token standard and make a beforeMint callback function call into an Extension.

Finally, create a PricedMint.sol file in the /src directory and paste the following:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract PricedMint {}

We’ll write an Extension contract that implements this beforeMint callback function and collects a price from a minter for minting tokens.

Writing a Core contract

In the ERC20Core.sol file, import ModularCore and inherit it.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ModularCore} from "lib/modular-contracts/src/ModularCore.sol";

contract ERC20Core is ModularCore {

		constructor() {
        _setOwner(msg.sender);
    }

    function name() public view override returns (string memory) {
        return "Test Token";
    }

    function symbol() public view override returns (string memory) {
        return "TEST";
    }
}

A Core contract must implement the IModularCore interface, and we do this for our core contract by simply inheriting ModularCore.

ModularCore is an Ownable contract; to keep it simple, we’ve added a constructor that simply makes the deployer the contract owner.

Now, let’s give the contract a mint function so we can mint tokens.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ModularCore} from "lib/modular-contracts/src/ModularCore.sol";
import {ERC20} from "lib/solady/src/tokens/ERC20.sol";

contract ERC20Core is ModularCore, ERC20 {
    // ... snip

    function mint(address to, uint256 amount) external payable {
        // TODO: perform validation and operations in `beforeMint` call.

        // Mint tokens
        _mint(to, amount);
    }
}

Here’s what we’re doing in the code snippet:

  • We have imported ERC20.sol from Solady and inherited it.
  • We’ve created a payable mint function that anyone can call to mint tokens.

Currently, the contract blindly mints tokens to the specified recipient. We’re going to create space for validation and any additional operations with a callback function call.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ModularCore} from "lib/modular-contracts/src/ModularCore.sol";
import {ERC20} from "lib/solady/src/tokens/ERC20.sol";

contract ERC20Core is ModularCore, ERC20 {
    // ... snip

    function mint(address to, uint256 amount) external payable {
        // Perform validation and operations in `beforeMint` call.
        _executeCallbackFunction(
						BeforeMintCallback.beforeMint.selector, 
						abi.encodeCall(BeforeMintCallback.beforeMint, (to, amount))
        );

        // Mint tokens
        _mint(to, amount);
    }
}

interface BeforeMintCallback {
    function beforeMint(address to, uint256 amount) external payable;
}

Here’s what’s happening in this code snippet:

  • We’ve created an interface for a beforeMint callback function. The job of this callback function will be to run some code every time someone tries to mint tokens on our contract.
  • We use the _executeCallbackFunction internal function (we get from ModularCore) to perform the callback function call.

Using a callback function call — and generally, the Modular Contract framework — you can customize the mint function with any code, even after your contract is deployed.

In our example build, we will write an Extension that lets us set a native token price for purchasing our tokens and in beforeMint check whether a minter has sent sufficient native tokens to pay this price.

We now need to override only one function before we’re done:


// Whether to revert if no installed Extension implements the callback.
enum CallbackMode {
    OPTIONAL,
    REQUIRED
}

// A callback functions's signature and mode (optional or required)
struct SupportedCallbackFunction {
    bytes4 selector;
    CallbackMode mode;
}

// Returns a list of callback functions an Extension is permitted to implement.
function getSupportedCallbackFunctions() external pure returns (SupportedCallbackFunction[] memory);

Let’s override this function as such:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ModularCore} from "lib/modular-contracts/src/ModularCore.sol";
import {ERC20} from "lib/solady/src/tokens/ERC20.sol";

contract ERC20Core is ModularCore, ERC20 {
    // ... snip

    function getSupportedCallbackFunctions()
        public
        pure
        virtual
        override
        returns (SupportedCallbackFunction[] memory supportedCallbacks)
    {
        supportedCallbacks = new SupportedCallbackFunction[](1);
        supportedCallbacks[0] = SupportedCallbackFunction(BeforeMintCallback.beforeMint.selector, CallbackMode.REQUIRED);
    }
}

// ... snip

Here’s the final code for ERC20Core.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ModularCore} from "lib/modular-contracts/src/ModularCore.sol";
import {ERC20} from "lib/solady/src/tokens/ERC20.sol";

contract ERC20Core is ModularCore, ERC20 {
    constructor() {
        _setOwner(msg.sender);
    }

    function name() public view override returns (string memory) {
        return "Test Token";
    }

    function symbol() public view override returns (string memory) {
        return "TEST";
    }

    function getSupportedCallbackFunctions()
        public
        pure
        virtual
        override
        returns (SupportedCallbackFunction[] memory supportedCallbacks)
    {
        supportedCallbacks = new SupportedCallbackFunction[](1);
        supportedCallbacks[0] = SupportedCallbackFunction(BeforeMintCallback.beforeMint.selector, CallbackMode.REQUIRED);
    }

    function mint(address to, uint256 amount) external payable {
        // Perform validation and operations in `beforeMint` call.
        _executeCallbackFunction(
            BeforeMintCallback.beforeMint.selector, abi.encodeCall(BeforeMintCallback.beforeMint, (to, amount))
        );

        // Mint tokens
        _mint(to, amount);
    }
}

interface BeforeMintCallback {
    function beforeMint(address to, uint256 amount) external payable;
}

Run forge build in your terminal and confirm that your contract compiles.

Writing an Extension contract

We’ll write an Extension for the ERC20Core contract that lets us set a native token price for purchasing our tokens and in beforeMint check whether a minter has sent sufficient native tokens to pay this price.

Paste the following in src/PricedMint.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Ownable} from "lib/solady/src/auth/Ownable.sol";

// See ERC-7201: Namespaced Storage Layout (<https://eips.ethereum.org/EIPS/eip-7201>)
library PricedMintStorage {
    bytes32 public constant PRICED_MINT_STORAGE_POSITION =
        keccak256(abi.encode(uint256(keccak256("priced.mint")) - 1)) & ~bytes32(uint256(0xff));

    struct Data {
        uint256 pricePerUnit;
    }

    function data() internal pure returns (Data storage data_) {
        bytes32 position = PRICED_MINT_STORAGE_POSITION;
        assembly {
            data_.slot := position
        }
    }
}

contract PricedMint is Ownable {
    function setPricePerUnit(uint256 price) external onlyOwner {
        PricedMintStorage.data().pricePerUnit = price;
    }

    function beforeMint(address _to, uint256 _amount) external payable {
        uint256 pricePerUnit = PricedMintStorage.data().pricePerUnit;

        // assumption: 1 unit of token has 18 decimals (i.e. ether units)
        uint256 expectedPrice = (_amount * pricePerUnit) / 1e18;

        require(msg.value == expectedPrice, "PricedMint: invalid price sent");

        // Distribute price to the contract owner.
        (bool success,) = owner().call{value: msg.value}("");
        require(success, "ERC20Core: failed to send value");
    }
}

Here’s what’s happening in this snippet:

  • We’ve added two functions to our contract:Note that the call to setPricePerUnit and beforeMint from the Core contract are delegateCalls. So, since ModularCore inherits Solady’s Ownable, we can also inherit it here in the Extension contract and use its modifiers and functions as if we were writing code in the Core contract.
    • setPricePerUnit let’s us set a native token price to charge per unit of ERC-20 token minted. Only the owner of the Core contract can call this function.
    • beforeMint checks whether a minter has sent sufficient native tokens to pay the mint price and it then distributes this mint price to the Core contract owner.
  • We create an ERC-7201 name-spaced storage layout PricedMintStorage for our contract (read more about this pattern here).This ensures the Extension is writing to and reading from a dedicated storage location. This is important to prevent writing to or reading from unintended storage slots of the contract.

We now turn PricedMint into an Extension by importing and inheriting ModularExtension.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ModularExtension} from "lib/modular-contracts/src/ModularExtension.sol";

// ... snip

contract PricedMint is Ownable, ModularExtension {
    // ... snip

    function getExtensionConfig() external pure virtual override returns (ExtensionConfig memory config) {
        config.callbackFunctions = new CallbackFunction[](1);
        config.callbackFunctions[0] = CallbackFunction(this.beforeMint.selector);

        config.fallbackFunctions = new FallbackFunction[](1);
        config.fallbackFunctions[0] = FallbackFunction(this.setPricePerUnit.selector, 0);
    }
}

In the Modular Contracts framework, a Core and Extension contract communicate and agree over compatibility before an Extension is installed into a Core.

When Core.installExtension is called, the Core asks the Extension which functions it implements by calling the getExtensionConfig function and reading its ExtensionConfig return value.

Here, the Extension specifies “fallback functions” i.e. which functions to make callable via the Core contract’s fallback functions, and “callback functions” which are called during the execution of a Core’s fixed functions.

In the snippet for PricedMint, we specify beforeMint as a callback function, and setPricePerUnit as a fallback function.

The final code for PricedMint.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ModularExtension} from "lib/modular-contracts/src/ModularExtension.sol";
import {Ownable} from "lib/solady/src/auth/Ownable.sol";

// See ERC-7201: Namespaced Storage Layout (<https://eips.ethereum.org/EIPS/eip-7201>)
library PricedMintStorage {
    bytes32 public constant PRICED_MINT_STORAGE_POSITION =
        keccak256(abi.encode(uint256(keccak256("priced.mint")) - 1)) & ~bytes32(uint256(0xff));

    struct Data {
        uint256 pricePerUnit;
    }

    function data() internal pure returns (Data storage data_) {
        bytes32 position = PRICED_MINT_STORAGE_POSITION;
        assembly {
            data_.slot := position
        }
    }
}

contract PricedMint is Ownable, ModularExtension {
    function setPricePerUnit(uint256 price) external onlyOwner {
        PricedMintStorage.data().pricePerUnit = price;
    }

    function beforeMint(address _to, uint256 _amount) external payable {
        uint256 pricePerUnit = PricedMintStorage.data().pricePerUnit;

        // assumption: 1 unit of token has 18 decimals (i.e. ether units)
        uint256 expectedPrice = (_amount * pricePerUnit) / 1e18;

        require(msg.value == expectedPrice, "PricedMint: invalid price sent");

        // Distribute price to the contract owner.
        (bool success,) = owner().call{value: msg.value}("");
        require(success, "ERC20Core: failed to send value");
    }

    function getExtensionConfig() external pure virtual override returns (ExtensionConfig memory config) {
        config.callbackFunctions = new CallbackFunction[](1);
        config.callbackFunctions[0] = CallbackFunction(this.beforeMint.selector);

        config.fallbackFunctions = new FallbackFunction[](1);
        config.fallbackFunctions[0] = FallbackFunction(this.setPricePerUnit.selector, 0);
    }
}

Run forge build in your terminal and confirm that your contracts compile.

Test Case

The following is a test case that illustrates the step-by-step flow we have achieved with the Modular Contracts framework.

Create test/Modular.t.sol and paste the code below. Run the tests by running forge test in your terminal.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test} from "forge-std/Test.sol";
import {ERC20Core} from "src/ERC20Core.sol";
import {PricedMint} from "src/PricedMint.sol";

contract ModularTest is Test {
    ERC20Core public erc20;
    PricedMint public pricedMintExtension;

    address owner = address(0x123);

    address minter = address(0x456);
    uint256 amount = 100 ether;

    uint256 pricePerUnit = 1 ether;

    function setUp() public {
        // Deploy contracts

        vm.prank(owner);
        erc20 = new ERC20Core();

        pricedMintExtension = new PricedMint();

        // Fund minter
        vm.deal(minter, 100 ether);

        // Install extension
        vm.prank(owner);
        erc20.installExtension(address(pricedMintExtension), "");

        // Set price per unit
        vm.prank(owner);
        PricedMint(address(erc20)).setPricePerUnit(pricePerUnit);
    }

    function test_mint() public {
        // Mint tokens
        assertEq(erc20.balanceOf(minter), 0);
        assertEq(minter.balance, 100 ether);
        assertEq(owner.balance, 0);

        vm.prank(minter);
        erc20.mint{value: 100 ether}(minter, amount);

        assertEq(erc20.balanceOf(minter), amount);
        assertEq(minter.balance, 0);
        assertEq(owner.balance, 100 ether);
    }

    function test_revert_mint_insufficientPriceSent() public {
        vm.prank(minter);
        vm.expectRevert();
        erc20.mint{value: 100 ether - 1}(minter, amount);
    }
}

Deploy a Modular Contract

We’ll first deploy our Core contract. Run the following in your terminal:

npx thirdweb@latest deploy

and select ERC20Core as the contract to deploy. This will pop up a web page for you to deploy the contract.

npx thirdweb deploy pops up a deployment web page

Connect your wallet and deploy the contract.

Connect wallet and click the 'Deploy' button

Here’s an example ERC20Core contract deployed on Sepolia: https://thirdweb.com/sepolia/0x189476873D1c6ab6270CA9CDADB8c2F9e4B4375D

Once you’re done deploying your contract, navigate to the Edit Extensions tab.

Edit Extensions tab of a modular contract

From here, you can install extensions published on the thirdweb platform.

Lookup and install any Extension contract published on thirdweb

Let’s now publish our PricedMint extension contract so we can install it in our ERC20Core contract. Run the following in your terminal:

npx thirdweb@latest publish

and select PricedMint as the contract to publish.

Once you’ve published the contract, come pack to your Core contract’s Edit Extensions tab, paste your wallet address and search for the Extension:

Install the PricedMint extension in your ERC20Core contract

Click the install button to install the Extension in your Core contract!

Now the setPricePerUnit function is callable on your Core contract and every call to the mint function will trigger a callback function call to the beforeMint function.

You can verify this via this Typescript script calling the setPricePerUnit function on your contract:

// Pre-requisite: install thirdweb <https://portal.thirdweb.com/typescript/v5>

import {
  createThirdwebClient,
  getContract,
  prepareContractCall,
  sendTransaction,
  waitForReceipt,
  toUnits,
} from "thirdweb";
import { privateKeyToAccount } from "thirdweb/wallets";
import { sepolia } from "thirdweb/chains";

const PRIVATE_KEY = ""; // Paste your private key here
const SECRET_KEY = ""; // Paste your secret key here

const TARGET_TOKEN_CORE_ADDRESS = ""; // Paste your target token core address here

const client = createThirdwebClient({
  secretKey: SECRET_KEY,
});

const account = privateKeyToAccount({
  client,
  privateKey: PRIVATE_KEY,
});

const contract = getContract({
  client,
  address: TARGET_TOKEN_CORE_ADDRESS,
  chain: sepolia,
});

const transaction = prepareContractCall({
  contract,
  method: {
    type: "function",
    name: "setPricePerUnit",
    inputs: [{ name: "price", type: "uint256", internalType: "uint256" }],
    outputs: [],
    stateMutability: "nonpayable",
  },
  params: [toUnits("0.0001", 18)],
});

async function main() {
  // Send transaction
  const result = await sendTransaction({
    transaction: transaction,
    account: account,
  });

  const receipt = await waitForReceipt(result);

  console.log("Set price per unit tx:", receipt.transactionHash);
}

main();

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.