Skip to content

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:

  1. Clone the repository:
    git clone https://github.com/m0-foundation/m-extensions.git
    cd m-extensions
  2. Follow the setup instructions:
    Refer to the README.md and follow all the commands provided to install dependencies and configure your development environment.

  3. 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. Configure Your Deployment

You have two options for deploying the MEarnerManager contract:

Option A: Use Existing Deployment Scripts (Quick Start)

The repository includes ready-to-use deployment scripts. To deploy the base MEarnerManager contract:

  1. Add your configuration to script/Config.sol:
// In the _getExtensionConfig() function, add your configuration
if (chainId_ == SEPOLIA_CHAIN_ID) {
    if (keccak256(bytes(name)) == keccak256(bytes("MEarnerManagerTestnet"))) {
        config.name = name;
        config.symbol = "MEM";
        config.admin = 0x...;
        config.earnerManager = 0x...;
        config.feeRecipient = 0x...;
    }
}
  1. Set environment variable and deploy:
export EXTENSION_NAME="MEarnerManagerTestnet"
export PRIVATE_KEY=0x...
 
forge script script/deploy/DeployMEarnerManager.s.sol \
    --rpc-url $SEPOLIA_RPC_URL \
    --broadcast \
    --verify

The deployment script (DeployMEarnerManager.s.sol) will read your configuration from Config.sol and deploy the extension.

Option B: Create Custom Deployment Script (Advanced)

If you need custom contract logic or deployment flow, write your own script.

See the repository's script/deploy/ directory for examples.

Example custom contract:

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
 
import { MEarnerManager } from "./projects/earnerManager/MEarnerManager.sol";
import { MExtension } from "./MExtension.sol";
 
contract InstitutionalUSD is MEarnerManager {
    constructor(address mToken_, address swapFacility_)
        MExtension(mToken_, swapFacility_) {}
 
    function initialize(
        string memory name,
        string memory symbol,
        address admin,
        address earnerManager,
        address feeRecipient_
    ) public override initializer {
        if (admin == address(0)) revert ZeroAdmin();
        if (earnerManager == address(0)) revert ZeroEarnerManager();
 
        __MExtension_init(name, symbol);
 
        _setFeeRecipient(feeRecipient_);
 
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(EARNER_MANAGER_ROLE, earnerManager);
    }
}

Key Parameters:

  • name: Token name (e.g., "Institutional M USD")
  • symbol: Token symbol (e.g., "iMUSD")
  • admin: Address with DEFAULT_ADMIN_ROLE (use multisig)
  • earnerManager: Address with EARNER_MANAGER_ROLE (platform operator)
  • feeRecipient_: Address that receives protocol fees

3. Write Deployment Script

M0 uses CREATE3 deployment for deterministic addresses across chains.

Reference: DeployMEarnerManager.s.sol

Create script/DeployInstitutionalUSD.s.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.26;
 
import { DeployHelpers } from "../lib/common/script/deploy/DeployHelpers.sol";
import { MEarnerManager } from "../src/projects/earnerManager/MEarnerManager.sol";
import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
 
contract DeployInstitutionalUSD is Script, DeployHelpers {
    address constant M_TOKEN = 0x866A2BF4E572CbcF37D5071A7a58503Bfb36be1b;
    address constant SWAP_FACILITY = 0x34F141daCB2DeF72D2196a473C585830af8B4004;
 
    function run() external {
        address deployer = vm.addr(vm.envUint("PRIVATE_KEY"));
 
        vm.startBroadcast(deployer);
 
        // Deploy implementation
        address implementation = address(new MEarnerManager(M_TOKEN, SWAP_FACILITY));
 
        // Compute deterministic salt for CREATE3
        bytes32 salt = _computeSalt(deployer, "InstitutionalUSD");
 
        // Deploy proxy via CREATE3
        address proxy = _deployCreate3TransparentProxy(
            implementation,
            vm.envAddress("ADMIN"),
            abi.encodeWithSelector(
                MEarnerManager.initialize.selector,
                "Institutional M USD",            // name
                "iMUSD",                          // symbol
                vm.envAddress("ADMIN"),           // admin
                vm.envAddress("EARNER_MANAGER"),  // earnerManager
                vm.envAddress("FEE_RECIPIENT")    // feeRecipient
            ),
            salt
        );
 
        vm.stopBroadcast();
 
        console.log("Implementation:", implementation);
        console.log("Proxy:", proxy);
        console.log("ProxyAdmin:", vm.envAddress("ADMIN"));
    }
}

Update your .env file:

# Deployment configuration
ADMIN=0x...
EARNER_MANAGER=0x...
FEE_RECIPIENT=0x...

4. Testing Your Contract

Test using fork tests or proxy deployment. Direct constructor + initialize calls won't work due to _disableInitializers().

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.26;
 
import { Test } from "forge-std/Test.sol";
import { DeployHelpers } from "../lib/common/script/deploy/DeployHelpers.sol";
import { MEarnerManager } from "../src/projects/earnerManager/MEarnerManager.sol";
 
contract MEarnerManagerTest is Test, DeployHelpers {
    MEarnerManager extension;
    address admin = makeAddr("admin");
    address earnerManager = makeAddr("earnerManager");
    address feeRecipient = makeAddr("feeRecipient");
    address client1 = makeAddr("client1");
    address client2 = makeAddr("client2");
 
    address constant M_TOKEN = 0x866A2BF4E572CbcF37D5071A7a58503Bfb36be1b;
    address constant SWAP_FACILITY = 0x34F141daCB2DeF72D2196a473C585830af8B4004;
 
    function setUp() public {
        vm.createSelectFork(vm.envString("SEPOLIA_RPC_URL"));
 
        address deployer = makeAddr("deployer");
        vm.deal(deployer, 10 ether);
 
        vm.startPrank(deployer);
 
        // Deploy via CREATE3
        address implementation = address(new MEarnerManager(M_TOKEN, SWAP_FACILITY));
 
        bytes32 salt = _computeSalt(deployer, "TestEarnerManager");
 
        address proxy = _deployCreate3TransparentProxy(
            implementation,
            admin,
            abi.encodeWithSelector(
                MEarnerManager.initialize.selector,
                "Test Institutional USD",
                "tIMUSD",
                admin,
                earnerManager,
                feeRecipient
            ),
            salt
        );
 
        extension = MEarnerManager(proxy);
        vm.stopPrank();
    }
 
    function testWhitelisting() public {
        vm.prank(earnerManager);
        extension.setAccountInfo(client1, true, 500);
 
        assertTrue(extension.isWhitelisted(client1));
        assertEq(extension.feeRateOf(client1), 500);
    }
 
    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;
        feeRates[1] = 1000;
 
        vm.prank(earnerManager);
        extension.setAccountInfo(accounts, statuses, feeRates);
 
        assertTrue(extension.isWhitelisted(client1));
        assertTrue(extension.isWhitelisted(client2));
    }
 
    function testEarningCanOnlyBeEnabledOnce() public {
        extension.enableEarning();
 
        vm.expectRevert();
        extension.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

source .env
 
forge script script/DeployInstitutionalUSD.s.sol \
    --rpc-url $SEPOLIA_RPC_URL \
    --broadcast \
    --verify \
    --etherscan-api-key $ETHERSCAN_API_KEY \
    -vvvv

Deploy to Mainnet

source .env
 
forge script script/DeployInstitutionalUSD.s.sol \
    --rpc-url $MAINNET_RPC_URL \
    --broadcast \
    --verify \
    --etherscan-api-key $ETHERSCAN_API_KEY

For production, use a hardware wallet:

forge script script/DeployInstitutionalUSD.s.sol \
    --rpc-url $MAINNET_RPC_URL \
    --ledger \
    --broadcast \
    --verify

Verify Deterministic Address

Predict your extension address before deployment:

bytes32 salt = _computeSalt(deployer, "InstitutionalUSD");
address expectedProxy = _getCreate3Address(deployer, salt);

The address will be identical on all chains with the same deployer and salt.

Enable Earning

After deployment and governance approval:

cast send $PROXY_ADDRESS "enableEarning()" \
    --rpc-url $SEPOLIA_RPC_URL \
    --private-key $PRIVATE_KEY

Important: Earning can only be enabled once. If disabled later, it cannot be re-enabled.

7. Application Logic & Integration

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

  1. 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);
    }
  2. 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);
    }
  3. 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