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.
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 versionsolc-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:
interfaceIModuleisIERC165, 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 */functioninit(address initTeam,bytesmemory 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 */functiongetParent() externalviewreturns (address);}
interface ITeamControlled {/** * @notice Returns the address of the team, which act similar to an 'owner' of the contract */functionteam() externalviewreturns (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 :
abstractcontractModuleisInitializable, ERC165Upgradeable, TeamControlled, IModule {constructor() {_disableInitializers(); }/// @inheritdoc IModulefunctioninit(address initTeam,bytesmemory params) externalvirtual;function__Module_init(address initTeam) internalvirtualonlyInitializing {__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) internalvirtualreturns (uint256) {uint256 moduleBeforeBalance =IERC20(asset).balanceOf(address(this));// Recursively spends from the organization's treasuryITeam(team()).spend(asset, amount);uint256 amountReceived =IERC20(asset).balanceOf(address(this)) - moduleBeforeBalance;emit Events.Spent(asset, amount, amountReceived);return amountReceived; }/// @inheritdoc IChildfunctiongetParent() publicviewvirtualreturns (address) {// The 'parent' of a module is the '_team' in TeamControlledreturnteam(); }/// @dev ERC165 supportfunctionsupportsInterface(bytes4 interfaceId)publicviewvirtualoverride(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:
abstractcontractTeamControlledisInitializable, ContextUpgradeable, ITeamControlled {/// @custom:storage-location erc7201:mezzanine.storage.TeamControlled.v1.0structTeamControlledStorage {address _team; }// keccak256(abi.encode(uint256(keccak256("mezzanine.storage.TeamControlled.v1.0")) - 1)) & ~bytes32(uint256(0xff))bytes32privateconstant TeamControlledStorageLocation =0xb816793abbf480753098fb767c6f1dcec3044cd562367c4739d88482505d6d00;function_getTeamControlledStorage() internalpurereturns (TeamControlledStoragestorage $) {assembly { $.slot := TeamControlledStorageLocation } }/// @dev Reverts if the caller is not the 'team'modifieronlyTeam() {_validateCallerIsTeam(); _; }/// @dev Reverts if the caller is not the 'team' or its ancestormodifieronlyTeamOrAncestor() {_validateCallerIsTeamOrAncestor(); _; }/// @dev Sets the '_team' variable and validate that it supports the ITeam interfacefunction__TeamControlled_init(address initTeam) internalvirtualonlyInitializing {if (!(ERC165Checker.supportsInterface(initTeam, type(ITeam).interfaceId))) {revert Errors.TeamControlledTeamDoesNotSupportTeamInterface(initTeam); }// Set 'team' TeamControlledStorage storage $ =_getTeamControlledStorage(); $._team = initTeam; }/// @inheritdoc ITeamControlledfunctionteam() publicviewvirtualreturns (address) { TeamControlledStorage storage $ =_getTeamControlledStorage();return $._team; }function_isCallerTeam() internalviewreturns (bool) {return_msgSender() ==team(); }/// @dev Reverts if the caller is not the 'team'function_validateCallerIsTeam() internalview {if (!(_isCallerTeam())) revert Errors.TeamControlledCallerNotTeam(_msgSender()); }/// @dev Reverts if the caller is not the 'team' or an ancestor of the 'team'function_validateCallerIsTeamOrAncestor() internalview { 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: MITpragmasolidity =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 contractcontractShareBuybackModuleisModule {usingSafeCastforuint256;usingSafeERC20forIERC20;eventExchangeRateSet(addressindexed setter, uint256 newExchangeRate);eventSharesBoughtBack(addressindexed seller, uint256 sharesSold, uint256 denominationAssetDistributed);eventFeeDistributed(uint256 amount);addresspublicimmutable FEE_RECEIVER;// Percentages are represented as 100_000 = 100%uint256publicconstant PRECISION_FACTOR =100_000;uint256publicconstant FEE_PERCENTAGE =1_000; // 1_000/100_000 = 1%/// @custom:storage-location erc7201:mezzanine.storage.ShareBuybackModule.v1.0structShareBuybackModuleStorage {address _treasury;uint96 _exchangeRate; }// keccak256(abi.encode(uint256(keccak256("mezzanine.storage.ShareBuybackModule.v1.0")) - 1)) & ~bytes32(uint256(0xff))bytes32privateconstant ShareBuybackModuleStorageLocation =0x3b8bfa8bd2e50cc6dc90186fa21102361a2dd4e0d242343ee79ef54ceffab400;function_getShareBuybackModuleStorage() internalpurereturns (ShareBuybackModuleStoragestorage $) {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 */functioninit(address initTeam,bytesmemory params) externaloverrideinitializer {__ShareBuybackModule_init(initTeam, params); }function__ShareBuybackModule_init(address initTeam,bytesmemory params) internalonlyInitializing { (address __treasury,uint256 __exchangeRate) = abi.decode(params, (address,uint256));// Validate the decoded valuesrequire(__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);emitExchangeRateSet(msg.sender, __exchangeRate); }/// @notice Returns the address of the 'treasury' contract as an ITreasuryfunctiontreasury() publicviewreturns (ITreasury) { ShareBuybackModuleStorage storage $ =_getShareBuybackModuleStorage();returnITreasury($._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 */functionexchangeRate() publicviewreturns (uint256) { ShareBuybackModuleStorage storage $ =_getShareBuybackModuleStorage();return $._exchangeRate; }/// @notice Sets the exchange rate. Only callable by the 'team' or its ancestorfunctionsetExchangeRate(uint256 newExchangeRate) externalonlyTeamOrAncestor {require(newExchangeRate >0,"ShareBuybackModule: new exchange rate must be greater than zero"); ShareBuybackModuleStorage storage $ =_getShareBuybackModuleStorage(); $._exchangeRate = newExchangeRate.toUint96();emitExchangeRateSet(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 */functionsellShares(uint256 amountToSell,uint256 expectedExchangeRate) external {// Validate the exchange raterequire(exchangeRate() >= expectedExchangeRate,"ShareBuybackModule: exchange rate lower than expected exchange rate" ); ITreasury treasuryCache =treasury();// Get the 'denominationAsset' and 'commonShares' from the treasuryaddress denominationAsset = treasuryCache.denominationAsset();address commonShares = treasuryCache.getCommonShares();uint256 denominationAssetOwed =calculateShareBuyback(amountToSell);uint256 fee =_calculateFee(denominationAssetOwed);// Transfer the 'commonShares' from the caller to the treasuryIERC20(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 callerIERC20(denominationAsset).safeTransfer(msg.sender, denominationAssetOwed);_distributeFee(denominationAsset, fee);emitSharesBoughtBack(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 */functioncalculateShareBuyback(uint256 amountToSell) publicviewreturns (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 selleruint256 denominationAssetOwed = (amountToSell *exchangeRate()) / commonSharesUnits;if (denominationAssetOwed ==0&& amountToSell >0) revert("ShareBuybackModule: precision loss");return denominationAssetOwed; }function_calculateFee(uint256 amount) internalpurereturns (uint256) {return amount * FEE_PERCENTAGE / PRECISION_FACTOR; }function_distributeFee(address denominationAssetCache,uint256 fee) internal {IERC20(denominationAssetCache).safeTransfer(FEE_RECEIVER, fee);emitFeeDistributed(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:
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 */functioninit(address initTeam,bytesmemory params) externaloverrideinitializer {__ShareBuybackModule_init(initTeam, params); }function__ShareBuybackModule_init(address initTeam,bytesmemory params) internalonlyInitializing { (address __treasury,uint256 __exchangeRate) = abi.decode(params, (address,uint256));// Validate the decoded valuesrequire(__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);emitExchangeRateSet(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 */functioncalculateShareBuyback(uint256 amountToSell) publicviewreturns (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 selleruint256 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 raterequire(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 treasuryIERC20(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 callerIERC20(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: MITpragmasolidity =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-1167contract ShareBuybackModuleFactory {usingClonesforaddress;eventShareBuybackModuleDeployed(addressindexed creator, addressindexed team, address module);addresspublicimmutable 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 */functiondeployShareBuybackModule(address team,bytesmemory params) externalreturns (address) {address module = SHARE_BUYBACK_MODULE_IMPLEMENTATION.clone();ShareBuybackModule(module).init(team, params);emitShareBuybackModuleDeployed(msg.sender, team, module);return module; }}
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