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

You have two options for deploying the MYieldFee contract:

Option A: Use Existing Deployment Scripts (Quick Start)

The repository includes ready-to-use deployment scripts. To deploy the base MYieldFee 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("MYieldToAllTestnet"))) {
        config.name = name;
        config.symbol = "MYTA";
        config.admin = 0x...;
        config.feeRate = 1000;  // 10% fee in basis points
        config.feeRecipient = 0x...;
        config.feeManager = 0x...;
        config.claimRecipientManager = 0x...;
    }
}
  1. Set environment variable and deploy:
export EXTENSION_NAME="MYieldToAllTestnet"
export PRIVATE_KEY=0x...
 
forge script script/deploy/DeployYieldToAllWithFee.s.sol \
    --rpc-url $SEPOLIA_RPC_URL \
    --broadcast \
    --verify

The deployment script (DeployYieldToAllWithFee.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 for L1 (Ethereum):

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
 
import { MYieldFee } from "./projects/yieldToAllWithFee/MYieldFee.sol";
import { MExtension } from "./MExtension.sol";
 
contract YieldUSD is MYieldFee {
    constructor(address mToken_, address swapFacility_)
        MExtension(mToken_, swapFacility_) {}
 
    function initialize(
        string memory name,
        string memory symbol,
        uint16 feeRate_,
        address feeRecipient_,
        address admin,
        address feeManager,
        address claimRecipientManager
    ) public override initializer {
        if (admin == address(0)) revert ZeroAdmin();
        if (feeManager == address(0)) revert ZeroFeeManager();
        if (claimRecipientManager == address(0)) revert ZeroClaimRecipientManager();
 
        __MExtension_init(name, symbol);
 
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(FEE_MANAGER_ROLE, feeManager);
        _grantRole(CLAIM_RECIPIENT_MANAGER_ROLE, claimRecipientManager);
 
        _setFeeRate(feeRate_);
        _setFeeRecipient(feeRecipient_);
 
        _getMYieldFeeStorageLocation().latestIndex = ContinuousIndexingMath.EXP_SCALED_ONE;
    }
}

For L2 (Spoke Chain):

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
 
import { MSpokeYieldFee } from "./projects/yieldToAllWithFee/MSpokeYieldFee.sol";
import { MExtension } from "./MExtension.sol";
 
contract YieldUSD is MSpokeYieldFee {
    constructor(address mToken_, address swapFacility_, address rateOracle_)
        MSpokeYieldFee(mToken_, swapFacility_, rateOracle_) {}
 
    function initialize(
        string memory name,
        string memory symbol,
        uint16 feeRate_,
        address feeRecipient_,
        address admin,
        address feeManager,
        address claimRecipientManager
    ) public override initializer {
        if (admin == address(0)) revert ZeroAdmin();
        if (feeManager == address(0)) revert ZeroFeeManager();
        if (claimRecipientManager == address(0)) revert ZeroClaimRecipientManager();
 
        __MExtension_init(name, symbol);
 
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(FEE_MANAGER_ROLE, feeManager);
        _grantRole(CLAIM_RECIPIENT_MANAGER_ROLE, claimRecipientManager);
 
        _setFeeRate(feeRate_);
        _setFeeRecipient(feeRecipient_);
 
        _getMYieldFeeStorageLocation().latestIndex = ContinuousIndexingMath.EXP_SCALED_ONE;
    }
}

Key Parameters:

  • name: Token name (e.g., "Yield Bearing USD")
  • symbol: 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 protocol fees
  • admin: Address with DEFAULT_ADMIN_ROLE (use multisig)
  • feeManager: Address with FEE_MANAGER_ROLE
  • claimRecipientManager: Address with CLAIM_RECIPIENT_MANAGER_ROLE

3. Write Deployment Script

M0 uses CREATE3 deployment for deterministic addresses across chains.

Reference: DeployYieldToAllWithFee.s.sol

Create script/DeployYieldUSD.s.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.26;
 
import { DeployHelpers } from "../lib/common/script/deploy/DeployHelpers.sol";
import { MYieldFee } from "../src/projects/yieldToAllWithFee/MYieldFee.sol";
import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
 
contract DeployYieldUSD 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 MYieldFee(M_TOKEN, SWAP_FACILITY));
 
        // Compute deterministic salt for CREATE3
        bytes32 salt = _computeSalt(deployer, "YieldUSD");
 
        // Deploy proxy via CREATE3
        address proxy = _deployCreate3TransparentProxy(
            implementation,
            vm.envAddress("ADMIN"),
            abi.encodeWithSelector(
                MYieldFee.initialize.selector,
                "Yield Bearing USD",                      // name
                "yUSD",                                   // symbol
                vm.envUint("FEE_RATE"),                   // feeRate (in bps)
                vm.envAddress("FEE_RECIPIENT"),           // feeRecipient
                vm.envAddress("ADMIN"),                   // admin
                vm.envAddress("FEE_MANAGER"),             // feeManager
                vm.envAddress("CLAIM_RECIPIENT_MANAGER")  // claimRecipientManager
            ),
            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...
FEE_RATE=1000  # 10% fee in basis points
FEE_RECIPIENT=0x...
FEE_MANAGER=0x...
CLAIM_RECIPIENT_MANAGER=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 { MYieldFee } from "../src/projects/yieldToAllWithFee/MYieldFee.sol";
 
contract MYieldFeeTest is Test, DeployHelpers {
    MYieldFee extension;
    address admin = makeAddr("admin");
    address feeRecipient = makeAddr("feeRecipient");
    address user = makeAddr("user");
 
    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 MYieldFee(M_TOKEN, SWAP_FACILITY));
 
        bytes32 salt = _computeSalt(deployer, "TestYieldFee");
 
        address proxy = _deployCreate3TransparentProxy(
            implementation,
            admin,
            abi.encodeWithSelector(
                MYieldFee.initialize.selector,
                "Test Yield USD",
                "tYUSD",
                1000,  // 10% fee
                feeRecipient,
                admin,
                admin,
                admin
            ),
            salt
        );
 
        extension = MYieldFee(proxy);
        vm.stopPrank();
    }
 
    function testFeeCollection() public {
        vm.prank(admin);
        extension.enableEarning();
 
        // Test fee collection after yield accrues
    }
 
    function testYieldClaiming() public {
        // Test user yield claiming
        uint256 accrued = extension.accruedYieldOf(user);
        // Test claim logic
    }
 
    function testIndexUpdating() public {
        vm.prank(admin);
        extension.enableEarning();
 
        uint128 indexBefore = extension.currentIndex();
        vm.warp(block.timestamp + 1 days);
        extension.updateIndex();
        uint128 indexAfter = extension.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

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

Deploy to Mainnet

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

For production, use a hardware wallet:

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

Verify Deterministic Address

Predict your extension address before deployment:

bytes32 salt = _computeSalt(deployer, "YieldUSD");
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

8. Integration & Usage

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.