Skip to content

Deploying a MYieldFee Extension

This guide provides a step-by-step walkthrough for deploying your own MYieldFee-based stablecoin for your users. It assumes you have already chosen this model and understand its architecture.

If you haven't already, please review the MYieldFee 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

Next, decide if you are deploying to L1 (MYieldFee.sol) or an L2 (MSpokeYieldFee.sol). The implementation steps are similar, but you will import a different base contract.

2. Create and Initialize Your Contract

Your contract will inherit from MYieldFee or MSpokeYieldFee. The initializer is key to configuring your token's economics, especially the feeRate.

Create a new file in the src/ directory, for example, YieldUSD.sol.

For L1 (Ethereum) Deployment:

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
 
import { MYieldFee } from "./projects/yieldToAllWithFee/MYieldFee.sol";
 
contract YieldUSD is MYieldFee {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor(address mToken_, address swapFacility_) MYieldFee(mToken_, swapFacility_) {
        _disableInitializers();
    }
 
    function initialize(
        string memory name,
        string memory symbol,
        uint16 feeRate_,
        address feeRecipient_,
        address admin,
        address feeManager,
        address claimRecipientManager
    ) public initializer {
        MYieldFee.initialize(
            name,                    // "Yield Bearing USD"
            symbol,                  // "yUSD"
            feeRate_,                // Fee rate in bps (e.g., 1000 = 10%)
            feeRecipient_,           // Protocol treasury address
            admin,                   // Admin multisig address
            feeManager,              // Fee manager address
            claimRecipientManager    // Claim recipient manager address
        );
    }
}

For L2 (Spoke Chain) Deployment:

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
 
import { MSpokeYieldFee } from "./projects/yieldToAllWithFee/MSpokeYieldFee.sol";
 
contract YieldUSD is MSpokeYieldFee {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor(address mToken_, address swapFacility_, address rateOracle_) 
        MSpokeYieldFee(mToken_, swapFacility_, rateOracle_) {
        _disableInitializers();
    }
 
    function initialize(
        string memory name,
        string memory symbol,
        uint16 feeRate_,
        address feeRecipient_,
        address admin,
        address feeManager,
        address claimRecipientManager
    ) public initializer {
        MSpokeYieldFee.initialize(
            name,                    // "Yield Bearing USD"
            symbol,                  // "yUSD"
            feeRate_,                // Fee rate in bps (e.g., 1000 = 10%)
            feeRecipient_,           // Protocol treasury address
            admin,                   // Admin multisig address
            feeManager,              // Fee manager address
            claimRecipientManager    // Claim recipient manager address
        );
    }
}

Key Parameters Explained:

  • name: The full name of your token (e.g., "Yield Bearing USD")
  • symbol: The token symbol (e.g., "yUSD")
  • feeRate_: Protocol fee in basis points (0-10,000). E.g., 1000 = 10% fee, 90% to users
  • feeRecipient_: Address that receives the protocol fees
  • admin: Address with DEFAULT_ADMIN_ROLE (should be a multisig)
  • feeManager: Address with FEE_MANAGER_ROLE
  • claimRecipientManager: Address with CLAIM_RECIPIENT_MANAGER_ROLE

Note: The mToken, swapFacility, and rateOracle (L2 only) 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:

For L1 & L2 Deployment:

// 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 { YieldUSD } from "../src/YieldUSD.sol";
 
contract DeployYieldUSD 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.
        // This pattern applies to both L1 (MYieldFee) and L2 (MSpokeYieldFee) deployments.
        address proxy = Upgrades.deployTransparentProxy(
            "YieldUSD.sol",         // Contract file name
            deployer,               // The admin for the proxy contract itself
            abi.encodeCall(         // The initializer call data
                YieldUSD.initialize,
                (
                    "Yield Bearing USD",      // name
                    "yUSD",                   // symbol
                    1000,                     // feeRate (10%)
                    0x...,                    // feeRecipient
                    0x...,                    // admin (DEFAULT_ADMIN_ROLE for the logic)
                    0x...,                    // feeManager
                    0x...                     // claimRecipientManager
                )
            )
        );
 
        vm.stopBroadcast();
 
        console.log("YieldUSD deployed at:", proxy);
    }
}

4. Testing Your Contract

Before deployment, create comprehensive tests, especially for yield claiming and fee calculations:

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
 
import { Test } from "forge-std/Test.sol";
import { YieldUSD } from "../src/YieldUSD.sol";
 
contract YieldUSDTest is Test {
    YieldUSD token;
    address admin = makeAddr("admin");
    address feeRecipient = makeAddr("feeRecipient");
    address user1 = makeAddr("user1");
    address user2 = makeAddr("user2");
 
    function setUp() public {
        token = new YieldUSD(
            address(mockMToken),       // M Token address
            address(mockSwapFacility)  // SwapFacility address
            // address(mockRateOracle) // For L2 only
        );
        
        token.initialize(
            "Yield Bearing USD",
            "yUSD",
            1000,                      // 10% fee rate
            feeRecipient,              // Fee recipient
            admin,                     // Admin
            admin,                     // Fee manager
            admin                      // Claim recipient manager
        );
    }
 
    function testYieldAccrual() public {
        // Test that yield accrues correctly to users
        // Mock M token yield accrual
        // Check accruedYieldOf for users
    }
 
    function testFeeCollection() public {
        // Test that protocol fees are collected correctly
        // Mock yield accrual
        // Call claimFee()
        // Assert fee goes to feeRecipient
    }
 
    function testYieldClaiming() public {
        // Test that users can claim their yield
        // Mock yield accrual
        // Call claimYieldFor(user)
        // Assert user receives correct amount
    }
 
    function testIndexUpdating() public {
        // Test that index updates correctly
        uint128 indexBefore = token.currentIndex();
        // Mock time passage and rate changes
        token.updateIndex();
        uint128 indexAfter = token.currentIndex();
        assertGt(indexAfter, indexBefore);
    }
}

5. Gain M0 Earner Approval

For your contract to accrue yield on the $M it holds, its deployed address must be approved as an M0 Earner via a governance vote.

Follow the process outlined in the Gaining Earner Approval guide.

6. Security & Audit

Security is critical for a user-facing yield product.

  • Thoroughly test all functions, especially yield claiming and fee calculations
  • Run static analysis tools like Slither
  • Pay special attention to the continuous indexing math
  • Consider obtaining an independent security audit for your implementation

7. Deploy & Launch

Deploy to Testnet First

# Load environment variables
source .env
 
# Deploy to Sepolia
forge script script/DeployYieldUSD.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/DeployYieldUSD.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/DeployYieldUSD.s.sol \
    --rpc-url $MAINNET_RPC_URL \
    --verify \
    --etherscan-api-key $ETHERSCAN_API_KEY \
    --ledger \
    --broadcast

Enable Earning

Once deployed and approved, call enableEarning() on your contract to start yield accrual:

// This initializes the index and starts yield accrual
yieldUSD.enableEarning();

8. Integration & Usage

Interacting with the SwapFacility

To convert between $M and your new extension, all interactions must go through the central, M0-deployed SwapFacility contract. However, for security, direct calls from end-users to this contract are restricted.

Key Concept: Whitelisted Swapper Contracts

End-users cannot call swapInM (to wrap $M) or swapOutM (to unwrap to $M) directly. The SwapFacility will reject these calls.

Instead, your application must use a dedicated intermediary contract that you deploy. This contract is what gets "whitelisted" by M0 Governance with the M_SWAPPER_ROLE. Your users will interact with your contract, which then securely calls the SwapFacility on their behalf.

Correct Integration Pattern:
  1. You Deploy an Intermediary Contract: This contract contains your application's logic for handling deposits and withdrawals.
  2. You Get It Whitelisted: Through a governance proposal, your intermediary contract's address is granted the M_SWAPPER_ROLE.
  3. Users Interact With Your Contract: The flow looks like this: UserYour Intermediary ContractSwapFacility.

For Your Application Users

  1. Wrapping M into Your Yield Token:
    // User approves SwapFacility to spend their $M
    mToken.approve(swapFacility, amount);
     
    // SwapFacility calls your contract's wrap function
    swapFacility.swapInM(yieldUSDAddress, amount, recipient);
  2. Claiming Yield:
    // Anyone can claim yield for a user
    uint256 yieldClaimed = yieldUSD.claimYieldFor(userAddress);
  3. Checking Balances:
    // Current balance (excluding unclaimed yield)
    uint256 balance = yieldUSD.balanceOf(userAddress);
     
    // Balance including unclaimed yield
    uint256 totalBalance = yieldUSD.balanceWithYieldOf(userAddress);
     
    // Just the unclaimed yield
    uint256 yield = yieldUSD.accruedYieldOf(userAddress);

For Protocol Management

  1. Claiming Protocol Fees:
    // Anyone can trigger fee collection
    uint256 feeClaimed = yieldUSD.claimFee();
  2. Managing Fee Rate:
    // Only FEE_MANAGER_ROLE can adjust fees
    yieldUSD.setFeeRate(1500); // Change to 15%
  3. Managing Fee Recipient:
    // Only FEE_MANAGER_ROLE can change recipient
    yieldUSD.setFeeRecipient(newTreasuryAddress);
  4. Managing Claim Recipients:
    // Only CLAIM_RECIPIENT_MANAGER_ROLE can redirect yield
    yieldUSD.setClaimRecipient(userAddress, vaultAddress);

Frontend Integration

Show users their growing balance in real-time:

// Get user's current balance plus unclaimed yield
const balanceWithYield = await yieldUSD.balanceWithYieldOf(userAddress);
 
// Get just the unclaimed yield amount
const unclaimedYield = await yieldUSD.accruedYieldOf(userAddress);
 
// Get current yield rate (after protocol fee)
const earnerRate = await yieldUSD.earnerRate();
 
// Get current index for advanced calculations
const currentIndex = await yieldUSD.currentIndex();
 
// Check total protocol fees available
const totalFees = await yieldUSD.totalAccruedFee();
 
// Get latest stored index and rate
const latestIndex = await yieldUSD.latestIndex();
const latestRate = await yieldUSD.latestRate();
const latestTimestamp = await yieldUSD.latestUpdateTimestamp();
 
// Check if earning is enabled
const isEarningEnabled = await yieldUSD.isEarningEnabled();
 
// Check claim recipient for a user
const claimRecipient = await yieldUSD.claimRecipientFor(userAddress);

User Experience Best Practices

  1. Auto-Update Balances: Use balanceWithYieldOf() to show users their real-time balance including accrued yield.

  2. Yield Claiming UI: Provide a clear "Claim Yield" button that calls claimYieldFor(userAddress).

  3. Yield History: Track YieldClaimed events to show users their yield claiming history.

  4. APY Display: Calculate and display the current APY based on the earnerRate().

9. Monitoring & Maintenance

Key Metrics to Track

  • Total Accrued Yield: How much yield is available for all users
  • Total Protocol Fees: Revenue generated from the fee mechanism
  • User Engagement: How often users are claiming their yield
  • Yield Rate: Current effective rate after protocol fees
  • Index Growth: Track how the index grows over time

Operational Considerations

  • Track gas costs for yield claiming operations.
  • Consider implementing automated yield claiming for small amounts.
  • Monitor the fee recipient balance for protocol revenue.
  • Set up alerts for when earning is disabled.
  • Implement a regular index update strategy (see below).

Index Update Strategy

For users to see their balances grow and for the on-chain state to accurately reflect the latest yield, the contract's index needs to be updated periodically. This is done by calling the public updateIndex() function, which checkpoints the current yield rate and index value into the contract's storage. This function can be called by any account.

While user transactions like wrap or claim will trigger an update, it is a best practice not to rely on user activity. Instead, you should set up an automated process to call updateIndex() on a regular schedule.

Suggested Strategies:
  • Backend Cron Job: The most common approach is to run a simple, time-based script from your backend (e.g., using a cron job) that calls the function.
  • Keeper Network: Use a decentralized automation service like Gelato or Chainlink Keepers to execute the call reliably.

The optimal frequency depends on your application's needs. Common schedules range from once per hour to once per day. This ensures the on-chain index never becomes too stale and provides a smooth experience for your users.

You can monitor the health of your update mechanism by comparing the live index with the last stored index:

// Monitor the "drift" between the live index and the last stored index.
// A large difference may indicate your automated update job is not running.
 
const currentIndex = await yieldUSD.currentIndex(); // Live, calculated value
const latestStoredIndex = await yieldUSD.latestIndex(); // Last value saved on-chain
 
console.log(`Current Index: ${currentIndex}`);
console.log(`Last Stored Index on-chain: ${latestStoredIndex}`);

Congratulations! You now have a fully functional yield-bearing stablecoin that automatically distributes yield to your users while generating protocol revenue through configurable fees.