How to Build Modular Smart Contracts with thirdweb SDK

How to Build Modular Smart Contracts with thirdweb SDK

How to Build Modular Contracts with Thirdweb SDK

Modular contracts are one of the latest features released by Thirdweb, allowing you to build highly customizable, upgradeable smart contracts where you can add or remove functionalities as needed. Think of them as Lego blocks - you have a core Lego block serving as the foundation, and on top of it you start adding more blocks. These additional blocks are the modular contracts.

In this guide, we'll dive deep into modular contracts, showing you how to use Thirdweb's powerful SDK to build your own core and modular contracts from scratch. We'll cover advanced topics like callback and fallback functions, and how to connect modules to the core contract to make the project work as a whole.

Check out the video version of this tutorial:

Prerequisites

  • A Thirirdweb account
  • A wallet like MetaMask to connect to Thirdweb
  • Node.js and yarn installed
  • Basic knowledge of Solidity and JavaScript

It's also helpful to have the Thirdweb documentation open as a reference.

Step 1: Create the Core Contract

First, let's create the core contract that will serve as the foundation for our modular system. We'll build an ERC20 token contract from scratch using Solidity.

  1. Open your terminal and run the following command to create a new contract using the Thirdweb CLI:
npx thirdweb create contract
  1. Select Hardhat or Forge as your framework. For this tutorial, we'll use Forge.
  2. Choose an empty contract when prompted for the contract name.
  3. Navigate to the project folder:
cd modular-contracts-from-scratch
  1. Open the project in your code editor.
  2. Delete the Contract.sol file in the src folder, as we won't be using it.
  3. Create a new file called ERC20Basic.sol in the src folder.
  4. Add the following code to ERC20Basic.sol:
//SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {ERC20} from "solady/tokens/ERC20.sol";
import {ECDSA} from "solady/utils/ECDSA.sol";
import {OwnableRoles} from "@solady/auth/OwnableRoles.sol";

contract ERC20Basic is ERC20, OwnableRoles {
    using ECDSA for bytes32;

    // Functions required by the Core.
    receive() external payable {}

    string private name_;
    string private symbol_;

    constructor(string memory _name, string memory _symbol, address owner) {
        name_ = _name;
        symbol_ = _symbol;
        _initializeOwner(owner);
    }

    function name() public view override returns (string memory) {
        return name_;
    }

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

    // Contract Functions

    function mint(
        address to,
        uint256 amount,
        bytes calldata data
    ) external payable {
        _mint(to, amount);
    }
}

This is a basic ERC20 token contract that mints an initial supply to the deployer and allows the owner to mint additional tokens.

Step 2: Install Dependencies

To implement the ERC20 token standard and modular contract functionality, we'll use the Solady and Thirdweb libraries.

  1. Install OpenZeppelin contracts:
forge install vectorized/solady --no-commit
  1. Install Thirdweb modular contracts:
forge install thirdweb-dev/modular-contracts --no-commit

Step 3: Make the Contract Modular

  1. Import the Core contract from Thirdweb modular contracts:
import {Core} from "@thirdweb-dev/src/Core.sol";
  1. Make `ERC20Basic§ inherit from `Core`:
contract ERC20Basic is ERC20, OwnableRoles, Core {
    // ...
}
  1. Implement the required `getSupportedCallbackFunctions` function:
 function getSupportedCallbackFunctions()
        public
        pure
        override
        returns (SupportedCallbackFunction[] memory supportedCallbackFunctions)
    {
        // leave empty for now.
    }
  1. Add a `receive` function to suppress compiler warnings, this is just a workaround and you would like to comment/delete this line before deploying.
receive() external payable {}
  1. Create a new file called MinCallbackERC20.sol in the src folder with the following code:
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.20;

contract BeforeMintCallbackERC20 {
    function beforeMintERC20(
        address _to,
        uint256 _amount,
        bytes memory _data
    ) external payable virtual returns (bytes memory result) {}
}

  1. Import BeforeMintCallbackERC20 into `ERC20Basic.sol`:
import {BeforeMintCallbackERC20} from "./BeforeMintCallbackERC20.sol";

  1. Add the beforeMintERC20 callback to the `mint` function:

    function mint(
        address to,
        uint256 amount,
        bytes calldata data
    ) external payable {
        _beforeMint(to, amount, data);
        _mint(to, amount);
    }
  1. Update getSupportedCallbackFunctions to include the `beforeMintERC20` callback:
function getSupportedCallbackFunctions()
        public
        pure
        override
        returns (SupportedCallbackFunction[] memory supportedCallbackFunctions)
    {
        supportedCallbackFunctions = new SupportedCallbackFunction[](1);
        supportedCallbackFunctions[0] = SupportedCallbackFunction({
            selector: BeforeMintCallbackERC20.beforeMintERC20.selector,
            mode: CallbackMode.OPTIONAL
        });
    }

This declares beforeMintERC20 as an optional callback function.

  1. You need to add another function _beforeMint which is going to execute the callback function that for now, is empty.
    function _beforeMint(
        address to,
        uint256 amount,
        bytes calldata data
    ) internal virtual {
        _executeCallbackFunction(
            BeforeMintCallbackERC20.beforeMintERC20.selector,
            abi.encodeCall(
                BeforeMintCallbackERC20.beforeMintERC20,
                (to, amount, data)
            )
        );
    }

Step 4: Deploy the Core Contract

With our modular core contract ready, let's deploy it to a testnet.

  1. Run the following command:
npx thirdweb deploy -k UseHereYourThirdwebAPIKey
  1. Select the contract to deploy (ERC20Basic).
  2. Choose the network to deploy to (e.g., Sepolia).
  3. Provide the constructor arguments (name, symbol, owner address).
  4. Confirm the deployment.

Once deployed, you'll see the contract in your Thirdweb dashboard.

Step 5: Test the Core Contract

Let's test our deployed core contract to ensure it works as expected.

  1. Create a new file called `test.js` with the following code:
const { privateKeyToAccount } = require("thirdweb/wallets");

const { defineChain } = require("thirdweb/chains");
const {
  createThirdwebClient,
  getContract,
  prepareContractCall,
  sendTransaction,
  waitForReceipt,
} = require("thirdweb");

const { parseUnits } = require("ethers");

// create the client with your clientId, or secretKey if in a server environment
const client = createThirdwebClient({
  clientId: "yourClientId",
});

const PRIVATE_KEY =
  "yourWalletPrivateKey"; // Paste your private key here, please don't use a wallet with real assets.

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

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

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

const valueInEther = "0.000000000001"; // Example Ether value
const valueInWei = BigInt(parseUnits(valueInEther, "ether"));

const transaction = prepareContractCall({
  contract,
  method: "function mint(address to, uint256 amount, bytes data) payable",
  params: ["yourWalletAddress", 10000000000, "0x"],
  // value: valueInWei,
});

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

  const receipt = await waitForReceipt(result);

  console.log("mint successfull", receipt.transactionHash);
}

main();

  1. Run the script:
node test.js

If the mint is successful, you'll see the transaction hash logged in the console. You can also check your wallet to confirm the receipt of the minted tokens.

At this point, our core contract is working as a regular ERC20 token. The beforeMintERC20 callback is empty, so it doesn't affect the minting process.

In the next part of this guide, we'll create a module contract that implements the beforeMintERC20 callback to add custom functionality to the minting process. We'll then connect the module to the core contract and test the modular system.

Step 6: Create the Module Contract

Now that we have our core contract set up, let's create a module contract that will implement the `beforeMintERC20` callback and add custom functionality to the minting process.

  1. Create a new file called PriceMintModule.sol in the src folder.
  2. Add the following code:
//SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {Module} from "modular-contracts/src/Module.sol";
import {BeforeMintCallbackERC20} from "modular-contracts/src/callback/BeforeMintCallbackERC20.sol";

contract PricedMintModule is Module, BeforeMintCallbackERC20 {
    uint256 public constant mintPrice = 0.000000000001 ether;

    function getModuleConfig()
        external
        pure
        override
        returns (ModuleConfig memory config)
    {
        config.callbackFunctions = new CallbackFunction[](1);
        config.callbackFunctions[0] = CallbackFunction({
            selector: this.beforeMintERC20.selector
        });

        config.requiredInterfaces = new bytes4[](1);
        config.requiredInterfaces[0] = 0x01ffc9a7;
    }

    function beforeMintERC20(
        address _to,
        uint256 _amount,
        bytes memory _data
    ) external payable virtual override returns (bytes memory result) {
        require(
            msg.value == mintPrice * _amount,
            "You need to pay the correct fee"
        );
    }
}

This module contract does the following:

  • Inherits from Module and BeforeMintCallbackERC20 contracts.
  • Defines a constant MINT_PRICE of 0.000000000001 ether.
  • Implements the beforeMintERC20 function, requiring the caller to pay the MINT_PRICE.
  • Implements the getModuleConfig function, specifying the supported callback function (beforeMintERC20) and required interface (IBaseERC20).

Step 7: Deploy the Module Contract

With our module contract ready, let's deploy it.

  1. Run the following command:
npx thirdweb publish -k yourThirdwebAPIKey
  1. Select the PriceMintModule contract to publish.
  2. Choose the network to publish to (e.g., Sepolia).
  3. Confirm the publication.

Once published, the module contract will appear in your Thirdweb dashboard.

Step 8: Install the Module

Now let's install the PriceMintModule to our core contract.

  1. Go to your core contract in the Thirdweb dashboard for the Core Contract.
  2. Navigate to the "Modules" tab.
  3. Click "Install Module".
  4. Select the PriceMintModule from the list of available modules.
  5. Confirm the installation.

Once installed, the beforeMintERC20 callback in the core contract will point to the implementation in the PriceMintModule.

Thirdweb Dashboard

Step 9: Test the Modular System

Let's test our modular system to ensure it works as expected.

  1. Update the test.js script to include the value parameter when calling the mint function:
const transaction = prepareContractCall({
  contract,
  method: "function mint(address to, uint256 amount, bytes data) payable",
  params: ["yourWalletAddress", 10000000000, "0x"],
  value: valueInWei,
});

  1. Run the script:
node test.js

If the mint is successful, you'll see the transaction hash logged in the console. The minter should have paid the required MINT_PRICE defined in the PriceMintModule.

  1. Try running the script again without providing the `value` parameter:
const transaction = prepareContractCall({
  contract,
  method: "function mint(address to, uint256 amount, bytes data) payable",
  params: ["yourWalletAddress", 10000000000, "0x"],
  // value: valueInWei,
});

You should see an error indicating that the correct fee was not paid.

  1. Uninstall the PriceMintModule from the core contract in the Thirdweb dashboard.
  2. Run the script again without the value parameter. The mint should succeed, as the beforeMintERC20 callback is no longer implemented.

And there you have it! We've successfully built a modular ERC20 token contract, created a custom module to add minting functionality, and tested the modular system.

Using Thirdweb's modular contracts and SDK, you can easily create extensible and upgradeable smart contracts without worrying about the complex underlying logic.

I hope this in-depth guide has given you a solid understanding of how to build modular contracts with Thirdweb. Feel free to explore more advanced topics and experiment with different module implementations.

Happy coding!

Step 10: Use Pre-Built Core Contracts

While we've gone through the process of creating a core contract and module from scratch to understand how modular contracts work under the hood, Thirdweb offers a simpler way to get started with pre-built, pre-audited core contracts.

Here's an example of how you can use the `ERC20Core` contract from Thirdweb:

//SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {ERC20Core} from "@thirdweb-dev/src/core/token/ERC20Core.sol";

contract ERC20CoreContract is ERC20Core {
    constructor(
        string memory _name,
        string memory _symbol,
        string memory _contractURI,
        address _owner,
        address[] memory _modules,
        bytes[] memory _moduleInstallData
    )
        ERC20Core(
            _name,
            _symbol,
            _contractURI,
            _owner,
            _modules,
            _moduleInstallData
        )
    {}

    // non nessecary receive function
    // receive() external payable {}
}

With just 24 lines of code, you have a basic ERC20 token with more functionality than the one we created from scratch. The `ERC20Core` contract includes implementations for:

  • beforeMint.
  • beforeMintWithSignature.
  • beforeTransfer.
  • beforeBurn.
  • beforeApprove.

This covers a wide range of use cases for an ERC20 token.

Going forward, I highly recommend using the pre-built core contracts from Thirdweb, such as ERC20Core, ERC721Core, or any other core contracts we offer. It will save you time and ensure your contracts are secure and optimized.

Conclusion

In this in-depth guide, we've covered a lot of ground on modular contracts:

  • Creating a core contract from scratch
  • Implementing a custom module contract
  • Connecting the module to the core contract
  • Using Thirdweb's pre-built core contracts

Modular contracts offer a powerful way to build highly customizable, upgradeable smart contracts where you can add or remove functionalities as needed.

While we've touched on many advanced topics, there's still more to explore with modular contracts. If you have any specific questions or topics you'd like us to cover, please let us know and we'll create content to address them.

If you found this guide too technical and want a higher-level overview, I recommend checking out our previous video on modular contracts that focuses on implementing them using the Thirdweb dashboard.

I hope you found this guide valuable and informative. Happy coding with modular contracts!