Creating a Custom Module

Technical teams can easily create custom modules in Mezzanine to suit their needs (or as 3rd party software for other Mezzanine companies). Mezzanine provides a base Module contract that can be inherited for hierarchical spending and access control.

Below, we implement a custom module that exchanges a company's common shares for the company's denomination asset based on an exchange rate using Foundry. This functionality is akin to share buybacks in traditional finance. We will also be taking a 1% fee, which will go to our wallet each time a share buyback is made.

Setup

Install Foundry via the following tutorial.

Run the following command to set up a new directory with Foundry set up:

forge init custom_module_example

Go to the directory and install the OpenZeppelin contracts:

cd custom_module_example
forge install OpenZeppelin/openzeppelin-contracts

The Mezzanine contracts are not public yet. Therefore, they cannot yet be installed via Foundry. They will be public soon.

Next, install the Mezzanine contracts:

forge install mezzanine-protocol/Mezzanine-Contracts

Change the foundry.toml file to look like the following:

[profile.default]
src = 'src'
out = 'out'
libs = ['lib']
remappings = ["@openzeppelin/=lib/openzeppelin-contracts/", "@mezzanine/=lib/Mezzanine-Contracts/src"]

# compiler version
solc-version = "0.8.20"

# See more config options https://github.com/foundry-rs/foundry/tree/master/config

Modules Overview

A valid module must support the IModule interface:

interface IModule is IERC165, IChild, ITeamControlled {
    /**
     * @notice Initializes the state of the module. This initialization is bespoke to each module
     * @param initTeam The team that controls the module
     * @param params The abi-encoded params to be decoded and passed to the module's initializer
     */
    function init(address initTeam, bytes memory params) external;
}

The initialization of a module is standardized. All bespoke initialization arguments should be abi-encoded in the params argument.

initTeam should support the Team base contract, which both departments and treasury inherit. This is unimportant for our purposes. Just remember that initTeam should be a valid Mezzanine department or treasury.

IModule inherits from IChild and ITeamControlled:

interface IChild {
    /**
     * @notice Returns the parent of 'this'. If 'this' is a department or module, returns the team that directly
     * controls 'this'. If 'this' is the Treasury, returns the sentinel parent, which is address(0x1)
     * @return The parent, which is either the treasury, a department, a module, or the sentinel parent
     */
    function getParent() external view returns (address);
}
interface ITeamControlled {
    /**
     * @notice Returns the address of the team, which act similar to an 'owner' of the contract
     */
    function team() external view returns (address);
}

Both the team and getParent functions should return the same value for most modules. However, this logic can be overridden if desired.

We will be inheriting from Mezzanine's Module base contract, which provides an internal function to recursively spend funds from the organization's treasury given sufficient balances and approvals :

abstract contract Module is Initializable, ERC165Upgradeable, TeamControlled, IModule {
    constructor() {
        _disableInitializers();
    }

    /// @inheritdoc IModule
    function init(address initTeam, bytes memory params) external virtual;

    function __Module_init(address initTeam) internal virtual onlyInitializing {
        __TeamControlled_init(initTeam);
    }

    /**
     * @dev Recusrively spends 'amount' of 'asset' from the organization's treasury
     * @param asset The asset to spend
     * @param amount The amount to spend
     * @return The amount received
     */
    function _spend(address asset, uint256 amount) internal virtual returns (uint256) {
        uint256 moduleBeforeBalance = IERC20(asset).balanceOf(address(this));

        // Recursively spends from the organization's treasury
        ITeam(team()).spend(asset, amount);

        uint256 amountReceived = IERC20(asset).balanceOf(address(this)) - moduleBeforeBalance;

        emit Events.Spent(asset, amount, amountReceived);

        return amountReceived;
    }

    /// @inheritdoc IChild
    function getParent() public view virtual returns (address) {
        // The 'parent' of a module is the '_team' in TeamControlled
        return team();
    }

    /// @dev ERC165 support
    function supportsInterface(bytes4 interfaceId)
        public
        view
        virtual
        override(ERC165Upgradeable, IERC165)
        returns (bool)
    {
        return interfaceId == type(IModule).interfaceId || super.supportsInterface(interfaceId);
    }
}

Module inherits from TeamControlled, which is a base contract that provides modifiers for hierarchical and direct access control:

abstract contract TeamControlled is Initializable, ContextUpgradeable, ITeamControlled {
    /// @custom:storage-location erc7201:mezzanine.storage.TeamControlled.v1.0
    struct TeamControlledStorage {
        address _team;
    }

    // keccak256(abi.encode(uint256(keccak256("mezzanine.storage.TeamControlled.v1.0")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant TeamControlledStorageLocation =
        0xb816793abbf480753098fb767c6f1dcec3044cd562367c4739d88482505d6d00;

    function _getTeamControlledStorage() internal pure returns (TeamControlledStorage storage $) {
        assembly {
            $.slot := TeamControlledStorageLocation
        }
    }

    /// @dev Reverts if the caller is not the 'team'
    modifier onlyTeam() {
        _validateCallerIsTeam();
        _;
    }

    /// @dev Reverts if the caller is not the 'team' or its ancestor
    modifier onlyTeamOrAncestor() {
        _validateCallerIsTeamOrAncestor();
        _;
    }

    /// @dev Sets the '_team' variable and validate that it supports the ITeam interface
    function __TeamControlled_init(address initTeam) internal virtual onlyInitializing {
        if (!(ERC165Checker.supportsInterface(initTeam, type(ITeam).interfaceId))) {
            revert Errors.TeamControlledTeamDoesNotSupportTeamInterface(initTeam);
        }

        // Set 'team'
        TeamControlledStorage storage $ = _getTeamControlledStorage();
        $._team = initTeam;
    }

    /// @inheritdoc ITeamControlled
    function team() public view virtual returns (address) {
        TeamControlledStorage storage $ = _getTeamControlledStorage();
        return $._team;
    }

    function _isCallerTeam() internal view returns (bool) {
        return _msgSender() == team();
    }

    /// @dev Reverts if the caller is not the 'team'
    function _validateCallerIsTeam() internal view {
        if (!(_isCallerTeam())) revert Errors.TeamControlledCallerNotTeam(_msgSender());
    }

    /// @dev Reverts if the caller is not the 'team' or an ancestor of the 'team'
    function _validateCallerIsTeamOrAncestor() internal view {
        AncestorLogic.validateCallerIsTeamOrAncestor(team());
    }
}

Writing the Custom Module

Our module should convert its company's common shares for the company's denomination asset based on an exchange rate. This exchange rate should be set only by the module's ancestors. This module should be deployed as a proxy. We will be using EIP1167 minimal, non-upgradeable proxies.

The following code has not been audited and should not be deployed in production

Here is the implementation of the ShareBuybackModule:

// SPDX-License-Identifier: MIT
pragma solidity =0.8.20;

import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ITreasury} from "@mezzanine/core/treasury/ITreasury.sol";
import {Module} from "@mezzanine/core/modules/Module.sol";

/// @title Share Buyback Module
/// @dev ERC7201 storage slots are used to avoid storage collisions in the proxy contract
contract ShareBuybackModule is Module {
    using SafeCast for uint256;
    using SafeERC20 for IERC20;

    event ExchangeRateSet(address indexed setter, uint256 newExchangeRate);
    event SharesBoughtBack(address indexed seller, uint256 sharesSold, uint256 denominationAssetDistributed);
    event FeeDistributed(uint256 amount);

    address public immutable FEE_RECEIVER;

    // Percentages are represented as 100_000 = 100%
    uint256 public constant PRECISION_FACTOR = 100_000;
    uint256 public constant FEE_PERCENTAGE = 1_000; // 1_000/100_000 = 1%

    /// @custom:storage-location erc7201:mezzanine.storage.ShareBuybackModule.v1.0
    struct ShareBuybackModuleStorage {
        address _treasury;
        uint96 _exchangeRate;
    }

    // keccak256(abi.encode(uint256(keccak256("mezzanine.storage.ShareBuybackModule.v1.0")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant ShareBuybackModuleStorageLocation =
        0x3b8bfa8bd2e50cc6dc90186fa21102361a2dd4e0d242343ee79ef54ceffab400;

    function _getShareBuybackModuleStorage() internal pure returns (ShareBuybackModuleStorage storage $) {
        assembly {
            $.slot := ShareBuybackModuleStorageLocation
        }
    }

    constructor(address _feeReceiver) {
        _disableInitializers();

        require(_feeReceiver != address(0), "ShareBuybackModule: fee receiver cannot be the zero address");

        FEE_RECEIVER = _feeReceiver;
    }

    /**
     * @notice Initializes the ShareBuybackModule with the 'initTeam', which acts as an admin of the contract
     * @param initTeam The address of the team that will control the ShareBuybackModule
     * @param params The abi-encoded parameters used for initialization
     */
    function init(address initTeam, bytes memory params) external override initializer {
        __ShareBuybackModule_init(initTeam, params);
    }

    function __ShareBuybackModule_init(address initTeam, bytes memory params) internal onlyInitializing {
        (address __treasury, uint256 __exchangeRate) = abi.decode(params, (address, uint256));

        // Validate the decoded values
        require(__treasury != address(0), "ShareBuybackModule: treasury cannot be the zero address");
        require(__exchangeRate > 0, "ShareBuybackModule: exchange rate must be greater than zero");

        // Initialize state
        ShareBuybackModuleStorage storage $ = _getShareBuybackModuleStorage();
        $._treasury = __treasury;
        $._exchangeRate = __exchangeRate.toUint96();

        // Sets the 'team' and 'parent'
        __Module_init(initTeam);

        emit ExchangeRateSet(msg.sender, __exchangeRate);
    }

    /// @notice Returns the address of the 'treasury' contract as an ITreasury
    function treasury() public view returns (ITreasury) {
        ShareBuybackModuleStorage storage $ = _getShareBuybackModuleStorage();
        return ITreasury($._treasury);
    }

    /**
     * @notice Returns the exchange rate of shares to the denomination asset. For each share provided,
     * the caller will receive the 'exchangeRate' amount of the denomination asset
     * @dev The exchange rate should be set in the denomination asset's decimals
     */
    function exchangeRate() public view returns (uint256) {
        ShareBuybackModuleStorage storage $ = _getShareBuybackModuleStorage();
        return $._exchangeRate;
    }

    /// @notice Sets the exchange rate. Only callable by the 'team' or its ancestor
    function setExchangeRate(uint256 newExchangeRate) external onlyTeamOrAncestor {
        require(newExchangeRate > 0, "ShareBuybackModule: new exchange rate must be greater than zero");

        ShareBuybackModuleStorage storage $ = _getShareBuybackModuleStorage();
        $._exchangeRate = newExchangeRate.toUint96();

        emit ExchangeRateSet(msg.sender, newExchangeRate);
    }

    /**
     * @notice Allows the caller to sell 'sharesToBuy' amount of shares to the module in exchange for the denomination asset
     * @param amountToSell The amount of shares to sell
     * @param expectedExchangeRate The expected exchange rate
     */
    function sellShares(uint256 amountToSell, uint256 expectedExchangeRate) external {
        // Validate the exchange rate
        require(
            exchangeRate() >= expectedExchangeRate,
            "ShareBuybackModule: exchange rate lower than expected exchange rate"
        );

        ITreasury treasuryCache = treasury();

        // Get the 'denominationAsset' and 'commonShares' from the treasury
        address denominationAsset = treasuryCache.denominationAsset();
        address commonShares = treasuryCache.getCommonShares();

        uint256 denominationAssetOwed = calculateShareBuyback(amountToSell);
        uint256 fee = _calculateFee(denominationAssetOwed);

        // Transfer the 'commonShares' from the caller to the treasury
        IERC20(commonShares).safeTransferFrom(msg.sender, address(treasuryCache), amountToSell);

        // Spend's the denomination asset recursively from the organization's treasury. Defined in Module.sol
        _spend(denominationAsset, denominationAssetOwed + fee);

        // Transfer the 'denominationAsset' to the caller
        IERC20(denominationAsset).safeTransfer(msg.sender, denominationAssetOwed);

        _distributeFee(denominationAsset, fee);

        emit SharesBoughtBack(msg.sender, amountToSell, denominationAssetOwed);
    }

    /**
     * @notice Returns the amount of the denomination asset owed to the seller for the 'amountToSell' shares, uninclusive of fees
     * @param amountToSell The amount of shares to sell
     * @return The amount of the denomination asset owed to the seller in the units of the denomination asset
     */
    function calculateShareBuyback(uint256 amountToSell) public view returns (uint256) {
        address commonShares = treasury().getCommonShares();
        uint256 commonSharesUnits = 10 ** ERC20(commonShares).decimals();

        // The 'exchangeRate' is in the denomination asset's decimals.  The 'amountToSell' is in the common share's decimals.
        // Dividing the product of the 'amountToSell' and the 'exchangeRate' by the 'commonSharesUnits' will return the amount
        // of the denomination asset owed to the seller
        uint256 denominationAssetOwed = (amountToSell * exchangeRate()) / commonSharesUnits;

        if (denominationAssetOwed == 0 && amountToSell > 0) revert("ShareBuybackModule: precision loss");

        return denominationAssetOwed;
    }

    function _calculateFee(uint256 amount) internal pure returns (uint256) {
        return amount * FEE_PERCENTAGE / PRECISION_FACTOR;
    }

    function _distributeFee(address denominationAssetCache, uint256 fee) internal {
        IERC20(denominationAssetCache).safeTransfer(FEE_RECEIVER, fee);

        emit FeeDistributed(fee);
    }
}

Let's break it down.

The fee receiver is set as an immutable variable in the constructor. Since immutable variables are stored in runtime rather than storage, the fee receiver will be the same across proxy deployments.

    constructor(address _feeReceiver) {
        _disableInitializers();

        require(_feeReceiver != address(0), "ShareBuybackModule: fee receiver cannot be the zero address");

        FEE_RECEIVER = _feeReceiver;
    }

We use ERC7201 storage slots to prevent storage collisions in case an upgradeable proxy is used for the deployment:

    /// @custom:storage-location erc7201:mezzanine.storage.ShareBuybackModule.v1.0
    struct ShareBuybackModuleStorage {
        address _treasury;
        uint96 _exchangeRate;
    }

    // keccak256(abi.encode(uint256(keccak256("mezzanine.storage.ShareBuybackModule.v1.0")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant ShareBuybackModuleStorageLocation =
        0x3b8bfa8bd2e50cc6dc90186fa21102361a2dd4e0d242343ee79ef54ceffab400;

    function _getShareBuybackModuleStorage() internal pure returns (ShareBuybackModuleStorage storage $) {
        assembly {
            $.slot := ShareBuybackModuleStorageLocation
        }
    }

The initializer function decodes the initial exchange rate and the treasury's address from params. Subsequently, it sets and validates the initial exchange rate and the treasury. We make a call to the base contract Module's internal initializer function to set the team:

    /**
     * @notice Initializes the ShareBuybackModule with the 'initTeam', which acts as an admin of the contract
     * @param initTeam The address of the team that will control the ShareBuybackModule
     * @param params The abi-encoded parameters used for initialization
     */
    function init(address initTeam, bytes memory params) external override initializer {
        __ShareBuybackModule_init(initTeam, params);
    }

    function __ShareBuybackModule_init(address initTeam, bytes memory params) internal onlyInitializing {
        (address __treasury, uint256 __exchangeRate) = abi.decode(params, (address, uint256));

        // Validate the decoded values
        require(__treasury != address(0), "ShareBuybackModule: treasury cannot be the zero address");
        require(__exchangeRate > 0, "ShareBuybackModule: exchange rate must be greater than zero");

        // Initialize state
        ShareBuybackModuleStorage storage $ = _getShareBuybackModuleStorage();
        $._treasury = __treasury;
        $._exchangeRate = __exchangeRate.toUint96();

        // Sets the 'team' and 'parent'
        __Module_init(initTeam);

        emit ExchangeRateSet(msg.sender, __exchangeRate);
    }

The exchange rate must be in the denomination asset's decimals. We adjust for decimals when calculating the amount of denomination asset owed to a seller:

    /**
     * @notice Returns the amount of the denomination asset owed to the seller for the 'amountToSell' shares, uninclusive of fees
     * @param amountToSell The amount of shares to sell
     * @return The amount of the denomination asset owed to the seller in the units of the denomination asset
     */
    function calculateShareBuyback(uint256 amountToSell) public view returns (uint256) {
        address commonShares = treasury().getCommonShares();
        uint256 commonSharesUnits = 10 ** ERC20(commonShares).decimals();

        // The 'exchangeRate' is in the denomination asset's decimals.  The 'amountToSell' is in the common share's decimals.
        // Dividing the product of the 'amountToSell' and the 'exchangeRate' by the 'commonSharesUnits' will return the amount
        // of the denomination asset owed to the seller
        uint256 denominationAssetOwed = (amountToSell * exchangeRate()) / commonSharesUnits;

        if (denominationAssetOwed == 0 && amountToSell > 0) revert("ShareBuybackModule: precision loss");

        return denominationAssetOwed;
    }

Finally, let's break down selling shares for a buyback.

First, we check for potential frontrunning. Since the exchange rate can be changed, a caller might call sellShares but be frontrun by the company. The company can specifically frontrun the user such that the conversion rate is much lower than what user expected. However, we validate this by requiring the exchange rate be greater than or equal to the user's expected rate. If it is lower, the transaction will revert, protecting the user.

        // Validate the exchange rate
        require(
            exchangeRate() >= expectedExchangeRate,
            "ShareBuybackModule: exchange rate lower than expected exchange rate"
        );

We next transfer the common shares from the user to the treasury. A user should approve the share buyback module to spend their shares before attempting to sell their shares:

   // Transfer the 'commonShares' from the caller to the treasury
        IERC20(commonShares).safeTransferFrom(msg.sender, address(treasuryCache), amountToSell);

We finally spend the denomination asset from the organization's treasury. We specifically spend the denomination asset owed to the user and the fee to send to the fee receiver:

        // Spend's the denomination asset recursively from the organization's treasury
        _spend(denominationAsset, denominationAssetOwed + fee);

        // Transfer the 'denominationAsset' to the caller
        IERC20(denominationAsset).safeTransfer(msg.sender, denominationAssetOwed);

        _distributeFee(denominationAsset, fee);

Remember, the team that inserts this module must have approvals to spend the denomination asset from its parent, and its parent must have approvals to spend from its parent, etc. A company should use ERC20 approvals as an upper limit for the number of shares to buybacks. However, a more complex implementation may set this upper limit explicitly as a storage variable.

Deploying the Custom Module

Modules are assumed to be deployed as proxies via a factory or deployer contract. The upgradeability logic of these proxies is up to the implementer. We will deploy our custom module using non-upgradeable EIP-1167 minimal proxies via OpenZeppelin's Clones library for simplicity. The proxy must be initialized atomically upon its deployment.

To be upgradeable, the implementation should be changed to inherit UUPSUpgradeable and be deployed via ERC1967 Proxies. Access control regarding upgradeability must also be added to the implementation, itself.

Here is a simple factory contract for the module:

// SPDX-License-Identifier: MIT
pragma solidity =0.8.20;

import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ShareBuybackModule} from "./ShareBuybackModule.sol";

/// @title Share Buyback Module Factory
/// @notice Deploys and initializes ShareBuybackModule modules via EIP-1167 proxies
/// @dev Reference: https://eips.ethereum.org/EIPS/eip-1167
contract ShareBuybackModuleFactory {
    using Clones for address;

    event ShareBuybackModuleDeployed(address indexed creator, address indexed team, address module);

    address public immutable SHARE_BUYBACK_MODULE_IMPLEMENTATION;

    constructor(address _shareBuybackModuleImplementation) {
        SHARE_BUYBACK_MODULE_IMPLEMENTATION = _shareBuybackModuleImplementation;
    }

    /**
     * @notice Deploys and initializes a new ShareBuybackModule
     * @param team The address of the team that will control the ShareBuybackModule
     * @param params The abi-encoded parameters used for initialization
     * @return module The address of the newly deployed ShareBuybackModule
     */
    function deployShareBuybackModule(address team, bytes memory params) external returns (address) {
        address module = SHARE_BUYBACK_MODULE_IMPLEMENTATION.clone();
        ShareBuybackModule(module).init(team, params);

        emit ShareBuybackModuleDeployed(msg.sender, team, module);

        return module;
    }
}

Deployment of the factory and the share buyback module can easily be completed via Foundry's Solidity scripting

The testing of the module can easily be done via Foundry's testing. Foundry's uses Solidity, itself, for its testing

Security Considerations for Modules

All modules should be properly audited before being used by an organization. Teams should set their ERC20 approvals for a module to zero or remove the module from their organization if a route for exploitation is discovered. The upgradeability and security mechanisms for the module are up to the implementer.

Optimizations for the Share Buyback Module Contract

The above implementation of the Share Buyback Module is a very simple contract. Here are some potential optimizations that can be made:

  • The use of custom error reverts rather than strings

  • Buybacks of different share classes

  • Buybacks using different denomination assets

  • SLOAD optimizations

  • ERC165 support

Last updated