Deploying a MEarnerManager Extension
This guide provides a step-by-step walkthrough for deploying a permissioned, institutional-grade MEarnerManager
-based stablecoin. It assumes you have already chosen this model and understand its architecture.
If you haven't already, please review the MEarnerManager
Deep Dive for conceptual details.
1. Setup Your Environment
First, you'll need to clone the m-extensions
repository and set up your development environment:
-
Clone the repository:
git clone https://github.com/m0-foundation/m-extensions.git cd m-extensions
-
Follow the setup instructions:
Refer to the README.md and follow all the commands provided to install dependencies and configure your development environment. -
Configure Environment Variables: Create a
.env
file in the root directory:# Private key for deployment (include 0x prefix) PRIVATE_KEY=0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef # RPC URLs MAINNET_RPC_URL=https://eth-mainnet.alchemyapi.io/v2/your-api-key SEPOLIA_RPC_URL=https://eth-sepolia.alchemyapi.io/v2/your-api-key # Etherscan API key for verification ETHERSCAN_API_KEY=your-etherscan-api-key
Security Warning: Never commit your
.env
file to version control. Add it to your.gitignore
:echo ".env" >> .gitignore
2. Create and Initialize Your Contract
Your custom contract will inherit from MEarnerManager
. In the initialize
function, you will set up the core roles and the initial fee recipient.
Create a new file in the src/
directory, for example, InstitutionalUSD.sol
.
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
import { MEarnerManager } from "./projects/earnerManager/MEarnerManager.sol";
contract InstitutionalUSD is MEarnerManager {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor(address mToken_, address swapFacility_) MEarnerManager(mToken_, swapFacility_) {
_disableInitializers();
}
function initialize(
string memory name,
string memory symbol,
address admin,
address earnerManager,
address feeRecipient_
) public initializer {
MEarnerManager.initialize(
name, // "Institutional M USD"
symbol, // "iMUSD"
admin, // Admin multisig address
earnerManager, // Platform operator address
feeRecipient_ // Protocol treasury address
);
}
}
Key Parameters Explained:
name
: The full name of your token (e.g., "Institutional M USD")symbol
: The token symbol (e.g., "iMUSD")admin
: Address withDEFAULT_ADMIN_ROLE
(should be a multisig)earnerManager
: Address withEARNER_MANAGER_ROLE
(your platform operator)feeRecipient_
: Address that receives all protocol fees
Note: The mToken
and swapFacility
addresses are set in the constructor as immutable variables, not in the initializer.
3. Write Deployment Script
Create a deployment script in the script/
directory:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
import { Script } from "forge-std/Script.sol";
import { Upgrades } from "openzeppelin-foundry-upgrades/Upgrades.sol";
import { InstitutionalUSD } from "../src/InstitutionalUSD.sol";
contract DeployInstitutionalUSD is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address deployer = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
// Deploying a Transparent Proxy, which requires a proxy admin.
address proxy = Upgrades.deployTransparentProxy(
"InstitutionalUSD.sol", // Contract file name
deployer, // The admin for the proxy contract itself
abi.encodeCall( // The initializer call data
InstitutionalUSD.initialize,
(
"Institutional M USD", // name
"iMUSD", // symbol
0x..., // admin (DEFAULT_ADMIN_ROLE for the logic)
0x..., // earnerManager
0x... // feeRecipient
)
)
);
vm.stopBroadcast();
console.log("InstitutionalUSD deployed at:", proxy);
}
}
4. Testing Your Contract
Before deployment, create comprehensive tests that focus on the whitelisting logic and fee mechanisms:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
import { Test } from "forge-std/Test.sol";
import { InstitutionalUSD } from "../src/InstitutionalUSD.sol";
contract InstitutionalUSDTest is Test {
InstitutionalUSD token;
address admin = makeAddr("admin");
address earnerManager = makeAddr("earnerManager");
address feeRecipient = makeAddr("feeRecipient");
address client1 = makeAddr("client1");
address client2 = makeAddr("client2");
address unauthorized = makeAddr("unauthorized");
function setUp() public {
token = new InstitutionalUSD(
address(mockMToken), // M Token address
address(mockSwapFacility) // SwapFacility address
);
token.initialize(
"Institutional M USD",
"iMUSD",
admin, // Admin
earnerManager, // Earner manager
feeRecipient // Fee recipient
);
}
function testWhitelisting() public {
// Test that only EARNER_MANAGER_ROLE can whitelist
vm.prank(earnerManager);
token.setAccountInfo(client1, true, 500); // 5% fee rate
assertTrue(token.isWhitelisted(client1));
assertEq(token.feeRateOf(client1), 500);
}
function testUnauthorizedAccess() public {
// Test that non-whitelisted addresses cannot interact
vm.expectRevert(abi.encodeWithSelector(token.NotWhitelisted.selector, unauthorized));
vm.prank(unauthorized);
// This should fail since unauthorized is not whitelisted
token.transfer(client1, 100);
}
function testYieldClaimWithFees() public {
// Test yield claiming with different fee rates
vm.prank(earnerManager);
token.setAccountInfo(client1, true, 1000); // 10% fee
// Mock yield accrual and test claiming
// Verify correct fee split between client and feeRecipient
}
function testBatchWhitelisting() public {
address[] memory accounts = new address[](2);
bool[] memory statuses = new bool[](2);
uint16[] memory feeRates = new uint16[](2);
accounts[0] = client1;
accounts[1] = client2;
statuses[0] = true;
statuses[1] = true;
feeRates[0] = 500; // 5%
feeRates[1] = 1000; // 10%
vm.prank(earnerManager);
token.setAccountInfo(accounts, statuses, feeRates);
assertTrue(token.isWhitelisted(client1));
assertTrue(token.isWhitelisted(client2));
assertEq(token.feeRateOf(client1), 500);
assertEq(token.feeRateOf(client2), 1000);
}
function testEarningCanOnlyBeEnabledOnce() public {
// Test that earning can only be enabled once
token.enableEarning();
vm.expectRevert(abi.encodeWithSelector(token.EarningCannotBeReenabled.selector));
token.enableEarning();
}
}
5. Gain M0 Earner Approval & Audit
Follow the standard procedure for getting your contract ready for mainnet:
- Gain M0 Earner approval for your contract address via governance. See the Gaining Earner Approval guide.
- Conduct a thorough security audit. Pay special attention to access control and the whitelisting logic.
6. Deploy and Enable Earning
Deploy to Testnet First
# Load environment variables
source .env
# Deploy to Sepolia
forge script script/DeployInstitutionalUSD.s.sol \
--rpc-url $SEPOLIA_RPC_URL \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY \
--broadcast
Deploy to Mainnet
# Load environment variables
source .env
# Deploy to Mainnet
forge script script/DeployInstitutionalUSD.s.sol \
--rpc-url $MAINNET_RPC_URL \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY \
--broadcast
Alternative: Hardware Wallet Deployment
For production deployments, consider using a hardware wallet:
forge script script/DeployInstitutionalUSD.s.sol \
--rpc-url $MAINNET_RPC_URL \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY \
--ledger \
--broadcast
Enable Earning
Once governance approval is confirmed, call enableEarning()
on your deployed contract to begin accruing yield:
// This starts the yield accrual process - can only be called ONCE
institutionalUSD.enableEarning();
Important: Remember that earning can only be enabled once. If you disable earning later, you cannot re-enable it.
7. Application Logic & Integration
This is the key step for MEarnerManager
. Your application's backend or admin panel must be built to interact with the contract's permissioning system.
Backend Integration for Client Management
// Example Node.js/Web3 integration for client onboarding
class InstitutionalUSDManager {
constructor(contract, earnerManagerSigner) {
this.contract = contract;
this.signer = earnerManagerSigner;
}
// Onboard a new institutional client
async onboardClient(clientAddress, feeRateBps) {
const tx = await this.contract
.connect(this.signer)
.setAccountInfo(clientAddress, true, feeRateBps);
await tx.wait();
console.log(`Client ${clientAddress} onboarded with ${feeRateBps} bps fee rate`);
}
// Batch onboard multiple clients
async batchOnboardClients(clientData) {
const addresses = clientData.map(c => c.address);
const statuses = clientData.map(() => true);
const feeRates = clientData.map(c => c.feeRate);
const tx = await this.contract
.connect(this.signer)
.setAccountInfo(addresses, statuses, feeRates);
await tx.wait();
console.log(`Batch onboarded ${clientData.length} clients`);
}
// Offboard a client
async offboardClient(clientAddress) {
const tx = await this.contract
.connect(this.signer)
.setAccountInfo(clientAddress, false, 0);
await tx.wait();
console.log(`Client ${clientAddress} offboarded`);
}
// Update client fee rate
async updateClientFeeRate(clientAddress, newFeeRate) {
const tx = await this.contract
.connect(this.signer)
.setAccountInfo(clientAddress, true, newFeeRate);
await tx.wait();
console.log(`Updated ${clientAddress} fee rate to ${newFeeRate} bps`);
}
// Claim yield for a client
async claimYieldForClient(clientAddress) {
const tx = await this.contract.claimFor(clientAddress);
const receipt = await tx.wait();
// Parse events to get yield amounts
const yieldEvent = receipt.events.find(e => e.event === 'YieldClaimed');
return yieldEvent.args.yield;
}
}
Client Management Workflows
-
Onboarding Process:
// When a new client is approved on your platform function onboardClient(address client, uint16 feeRate) external onlyAuthorized { // Verify client through your KYC/compliance process require(isKYCApproved(client), "Client not KYC approved"); // Whitelist them on the contract institutionalUSD.setAccountInfo(client, true, feeRate); emit ClientOnboarded(client, feeRate); }
-
Offboarding Process:
// When a client relationship ends function offboardClient(address client) external onlyAuthorized { // Claim any pending yield first institutionalUSD.claimFor(client); // Remove from whitelist institutionalUSD.setAccountInfo(client, false, 0); emit ClientOffboarded(client); }
-
Automated Yield Distribution:
// Periodic yield claiming for all clients function distributeYieldToAllClients() external onlyAuthorized { address[] memory clients = getActiveClients(); for (uint i = 0; i < clients.length; i++) { uint256 accruedYield = institutionalUSD.accruedYieldOf(clients[i]); if (accruedYield > minYieldThreshold) { institutionalUSD.claimFor(clients[i]); } } }
Frontend Integration for Clients
// Client dashboard showing their institutional account
// Check if user is whitelisted
const isWhitelisted = await institutionalUSD.isWhitelisted(clientAddress);
if (isWhitelisted) {
// Get client's fee rate
const feeRate = await institutionalUSD.feeRateOf(clientAddress);
// Get current balance
const balance = await institutionalUSD.balanceOf(clientAddress);
// Get balance including unclaimed yield
const balanceWithYield = await institutionalUSD.balanceWithYieldOf(clientAddress);
// Get detailed yield information
const [yieldWithFee, fee, yieldNetOfFee] = await institutionalUSD.accruedYieldAndFeeOf(clientAddress);
// Get principal amount
const principal = await institutionalUSD.principalOf(clientAddress);
// Display to client
console.log(`Balance: ${balance}`);
console.log(`Total with yield: ${balanceWithYield}`);
console.log(`Unclaimed yield (net): ${yieldNetOfFee}`);
console.log(`Fee rate: ${feeRate / 100}%`);
console.log(`Principal: ${principal}`);
}
8. Monitoring & Maintenance
Key Metrics to Track
- Active Clients: Number of whitelisted addresses
- Fee Revenue: Total fees collected by the protocol
- Client Yield: Individual and aggregate yield distributed to clients
- Average Fee Rate: Weighted average fee rate across all clients
- Total Principal: Total principal amount across all clients
Operational Considerations
- Regular Yield Claims: Set up automated processes to claim yield for clients regularly
- Fee Rate Optimization: Monitor and adjust individual client fee rates based on business relationships
- Compliance Monitoring: Track all whitelist changes for audit trails
- Gas Optimization: Batch operations when possible to reduce transaction costs
Administrative Functions
// Example admin functions for ongoing management
// Update the global fee recipient
await institutionalUSD.setFeeRecipient(newTreasuryAddress);
// Get contract state information
const feeRecipient = await institutionalUSD.feeRecipient();
const totalPrincipal = await institutionalUSD.totalPrincipal();
const projectedSupply = await institutionalUSD.projectedTotalSupply();
const isEarningEnabled = await institutionalUSD.isEarningEnabled();
const wasEarningEnabled = await institutionalUSD.wasEarningEnabled();
const disableIndex = await institutionalUSD.disableIndex();
const currentIndex = await institutionalUSD.currentIndex();
// Batch claim for multiple clients
const clients = [client1, client2, client3];
const [yieldWithFees, fees, yieldNetOfFees] = await institutionalUSD.claimFor(clients);
Error Handling
Be prepared to handle these specific errors:
// Handle MEarnerManager specific errors
try {
await institutionalUSD.setAccountInfo(client, true, feeRate);
} catch (error) {
if (error.message.includes("NotWhitelisted")) {
console.log("Account not whitelisted for this operation");
} else if (error.message.includes("InvalidFeeRate")) {
console.log("Fee rate exceeds maximum allowed (10,000 bps)");
} else if (error.message.includes("InvalidAccountInfo")) {
console.log("Cannot set non-zero fee rate for non-whitelisted account");
}
}
Congratulations! You now have a fully functional institutional-grade stablecoin that provides:
- Granular Control: Individual fee rates and whitelisting for each client
- Compliance Ready: Built-in permissioning system for regulatory requirements
- Revenue Generation: Customizable fee structures for different client tiers
- Professional Grade: Enterprise-level access control and audit trails
- One-Time Earning: Secure earning mechanism that can only be enabled once