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.
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:
- write your own Solidity modular contract,
- deploy it, and
- upgrade its functionality after deployment.
Background
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.
As an analogy, think of the robot, below. The torso is the Core to which you attach/detach 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:
- New functions become callable on the Core contract (via its fallback function).
- Core contract’s fixed functions make callback function calls into the 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
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
andbeforeMint
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.
Connect your wallet and deploy the contract.
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.
From here, you can install extensions published on the thirdweb platform.
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:
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.