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.
- Open your terminal and run the following command to create a new contract using the Thirdweb CLI:
npx thirdweb create contract
- Select
Hardhat
orForge
as your framework. For this tutorial, we'll use Forge. - Choose an empty contract when prompted for the contract name.
- Navigate to the project folder:
cd modular-contracts-from-scratch
- Open the project in your code editor.
- Delete the
Contract.sol
file in thesrc
folder, as we won't be using it. - Create a new file called
ERC20Basic.sol
in thesrc
folder. - 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.
- Install OpenZeppelin contracts:
forge install vectorized/solady --no-commit
- Install Thirdweb modular contracts:
forge install thirdweb-dev/modular-contracts --no-commit
Step 3: Make the Contract Modular
- Import the
Core
contract from Thirdweb modular contracts:
import {Core} from "@thirdweb-dev/src/Core.sol";
- Make `ERC20Basic§ inherit from `Core`:
contract ERC20Basic is ERC20, OwnableRoles, Core {
// ...
}
- Implement the required `getSupportedCallbackFunctions` function:
function getSupportedCallbackFunctions()
public
pure
override
returns (SupportedCallbackFunction[] memory supportedCallbackFunctions)
{
// leave empty for now.
}
- 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 {}
- Create a new file called
MinCallbackERC20.sol
in thesrc
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) {}
}
- Import
BeforeMintCallbackERC20
into `ERC20Basic.sol`:
import {BeforeMintCallbackERC20} from "./BeforeMintCallbackERC20.sol";
- 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);
}
- 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.
- 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.
- Run the following command:
npx thirdweb deploy -k UseHereYourThirdwebAPIKey
- Select the contract to deploy (
ERC20Basic
). - Choose the network to deploy to (e.g., Sepolia).
- Provide the constructor arguments (name, symbol, owner address).
- 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.
- 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();
- 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.
- Create a new file called
PriceMintModule.sol
in thesrc
folder. - 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
andBeforeMintCallbackERC20
contracts. - Defines a constant
MINT_PRICE
of 0.000000000001 ether. - Implements the
beforeMintERC20
function, requiring the caller to pay theMINT_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.
- Run the following command:
npx thirdweb publish -k yourThirdwebAPIKey
- Select the
PriceMintModule
contract to publish. - Choose the network to publish to (e.g., Sepolia).
- 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.
- Go to your core contract in the Thirdweb dashboard for the Core Contract.
- Navigate to the "Modules" tab.
- Click "Install Module".
- Select the
PriceMintModule
from the list of available modules. - Confirm the installation.
Once installed, the beforeMintERC20
callback in the core contract will point to the implementation in the PriceMintModule
.
Step 9: Test the Modular System
Let's test our modular system to ensure it works as expected.
- Update the
test.js
script to include thevalue
parameter when calling themint
function:
const transaction = prepareContractCall({
contract,
method: "function mint(address to, uint256 amount, bytes data) payable",
params: ["yourWalletAddress", 10000000000, "0x"],
value: valueInWei,
});
- 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
.
- 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.
- Uninstall the
PriceMintModule
from the core contract in the Thirdweb dashboard. - Run the script again without the
value
parameter. The mint should succeed, as thebeforeMintERC20
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!