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:
-
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
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 usersfeeRecipient_
: Address that receives the protocol feesadmin
: Address withDEFAULT_ADMIN_ROLE
(should be a multisig)feeManager
: Address withFEE_MANAGER_ROLE
claimRecipientManager
: Address withCLAIM_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.
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.
- You Deploy an Intermediary Contract: This contract contains your application's logic for handling deposits and withdrawals.
- You Get It Whitelisted: Through a governance proposal, your intermediary contract's address is granted the
M_SWAPPER_ROLE
. - Users Interact With Your Contract: The flow looks like this:
User
→Your Intermediary Contract
→SwapFacility
.
For Your Application Users
-
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);
-
Claiming Yield:
// Anyone can claim yield for a user uint256 yieldClaimed = yieldUSD.claimYieldFor(userAddress);
-
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
-
Claiming Protocol Fees:
// Anyone can trigger fee collection uint256 feeClaimed = yieldUSD.claimFee();
-
Managing Fee Rate:
// Only FEE_MANAGER_ROLE can adjust fees yieldUSD.setFeeRate(1500); // Change to 15%
-
Managing Fee Recipient:
// Only FEE_MANAGER_ROLE can change recipient yieldUSD.setFeeRecipient(newTreasuryAddress);
-
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
-
Auto-Update Balances: Use
balanceWithYieldOf()
to show users their real-time balance including accrued yield. -
Yield Claiming UI: Provide a clear "Claim Yield" button that calls
claimYieldFor(userAddress)
. -
Yield History: Track
YieldClaimed
events to show users their yield claiming history. -
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.
- 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.