Institutional Model
Core Concept: Three-tier permissioned architecture where an admin delegates authority to Earn Managers, who manage their own sets of earners with custom fee arrangements.
When to Use:- Institutional platforms serving multiple client organizations
- B2B services requiring bespoke yield-sharing agreements
- Prime brokerages with per-client fee structures
- Platforms needing granular control over who can hold and earn yield
EVM Implementation: MEarnerManager
The MEarnerManager extension is a powerful model designed for institutional, B2B, and permissioned use cases. It provides granular, per-account control over who can hold the token and what share of the yield they receive.
This model is the perfect choice for fintech platforms, prime brokerages, or any application serving a limited number of clients who require bespoke, onchain enforced fee structures. Its core feature is a whitelist: only approved addresses can interact with the token.
Source Code: MEarnerManager.sol
Architecture and Mechanism
The architecture of MEarnerManager revolves around a central manager role that curates a list of approved "earners" with individual fee arrangements.
Roles (Access Control)
DEFAULT_ADMIN_ROLE: The contract administrator, responsible for granting and revoking roles.EARNER_MANAGER_ROLE: The gatekeeper and controller of the extension's economics. This role is responsible for:- Whitelisting and de-listing accounts via
setAccountInfo - Setting a custom
feeRatefor each whitelisted account (0-10,000 basis points) - Setting the global
feeRecipientaddress that collects all fees
- Whitelisting and de-listing accounts via
How It Works
- Whitelisting is Mandatory: Before any address can hold, receive, wrap, unwrap, approve, or transfer the token, it must be added to the whitelist by the
EARNER_MANAGER_ROLEvia thesetAccountInfofunction. - Custom Fee Rates: When an account is whitelisted, the manager assigns it a specific
feeRate(from 0% to 100%). This allows for different commercial agreements with different clients. - Earning Control: The contract can only enable earning once using
enableEarning(). Once disabled, it cannot be re-enabled (enforced by theEarningCannotBeReenablederror). - Yield Accrual: The contract uses a principal-based system similar to
MYieldFee. Each user has aprincipalamount and their yield is calculated based on the difference between their present amount and current principal balance. - On-Demand Claiming: Yield for a specific user is only realized when
claimFor(account)is called. This can be called by anyone on behalf of the whitelisted account. - Fee Splitting: The
claimForfunction calculates the total yield for the account since its last claim, then splits it:- The user receives
Total Yield * (1 - feeRate) - The
feeRecipientreceivesTotal Yield * feeRate
- The user receives
This mechanism provides ultimate control and ensures that all token interactions are confined to a known set of participants.
Key Interface & Functions
Storage Layout
The storage is designed around the Account struct, keeping each user's data neatly organized.
// Each whitelisted user has an Account struct
struct Account {
uint256 balance; // Current token balance
bool isWhitelisted; // Whitelist status
uint16 feeRate; // Fee rate in basis points (0-10,000)
uint112 principal; // Principal amount for yield calculation
}
struct MEarnerManagerStorageStruct {
address feeRecipient;
uint256 totalSupply;
uint112 totalPrincipal;
bool wasEarningEnabled;
uint128 disableIndex;
mapping(address account => Account) accounts;
}Management Functions (The Control Panel)
These are the primary functions used by the EARNER_MANAGER_ROLE.
setAccountInfo(address account, bool status, uint16 feeRate) external: The main function to manage the whitelist. It can add an account, remove it, or update itsfeeRate. Claims yield for the account before making changes. Reverts withInvalidAccountInfoif trying to set a non-zero fee rate for a non-whitelisted account.setAccountInfo(address[] calldata accounts, bool[] calldata statuses, uint16[] calldata feeRates) external: A batch version for managing multiple accounts in one transaction. Reverts withArrayLengthMismatchif array lengths don't match orArrayLengthZeroif arrays are empty.setFeeRecipient(address feeRecipient_) external: Sets or updates the single address that will receive all collected fees. The fee recipient is automatically whitelisted with a 0% fee rate.
Core Yield & Claim Functions
claimFor(address account) external: The function to trigger a yield claim and fee split for a specific whitelisted account. Returns(yieldWithFee, fee, yieldNetOfFee). Can be called by anyone.claimFor(address[] calldata accounts) external: Batch version that claims yield for multiple accounts. Returns arrays of yield amounts, fees, and net yields.
Earning Control Functions
enableEarning() external: Starts earning yield on the held$M. Can only be called once - if earning was previously enabled, it reverts withEarningCannotBeReenabled.disableEarning() external: Stops earning yield and records the disable index. Reverts withEarningIsDisabledif already disabled.
View Functions
isWhitelisted(address account) external view: A simple check to see if an account is on the approved list. This is used as a gate for all transfers, wraps, and unwraps.feeRateOf(address account) external view: Returns the custom fee rate for a specific account.principalOf(address account) external view: Returns the principal amount for an account.accruedYieldAndFeeOf(address account) external view: Calculates the current claimable yield, the fee portion, and the net yield for an account without executing the claim. Returns(yieldWithFee, fee, yieldNetOfFee).accruedYieldOf(address account) external view: Returns only the net yield after fees for an account.accruedFeeOf(address account) external view: Returns only the fee portion of the yield for an account.balanceWithYieldOf(address account) external view: Returns the account's current balance plus accrued net yield.feeRecipient() external view: Returns the current fee recipient address.totalPrincipal() external view: Returns the total principal of all accounts.projectedTotalSupply() external view: Shows what the total supply would be if all yield was claimed.disableIndex() external view: Returns the index when earning was disabled (0 if never disabled).wasEarningEnabled() external view: Returns whether earning was ever enabled.isEarningEnabled() external view: Returns whether earning is currently enabled (was enabled and not disabled).currentIndex() external view: Returns the current index for yield calculations. Uses the disable index if earning is disabled, otherwise uses the M token's current index.
Access Control Enforcement
All operations (wrap, unwrap, transfer, approve) enforce that all involved parties are whitelisted:
_beforeApprove()- Checks both account and spender are whitelisted_beforeWrap()- Checks earning is enabled and both depositor and recipient are whitelisted_beforeUnwrap()- Checks the account is whitelisted_beforeTransfer()- Checks msg.sender, sender, and recipient are all whitelisted
Constants
ONE_HUNDRED_PERCENT: Set to 10,000 (representing 100% in basis points)EARNER_MANAGER_ROLE:keccak256("EARNER_MANAGER_ROLE")
Error Handling
The contract includes comprehensive error handling:
NotWhitelisted(address account): Thrown when a non-whitelisted account tries to interactEarningCannotBeReenabled: Thrown when trying to enable earning after it was already enabled onceInvalidFeeRate: Thrown when fee rate exceeds 100%InvalidAccountInfo: Thrown when trying to set non-zero fee rate for non-whitelisted accountZeroAdmin: Thrown when admin address is 0x0ZeroEarnerManager: Thrown when earner manager address is 0x0ZeroFeeRecipient: Thrown when fee recipient address is 0x0ZeroAccount: Thrown when account address is 0x0ArrayLengthMismatch: Thrown when array lengths don't match in batch operationsArrayLengthZero: Thrown when arrays are empty in batch operations
Ready to build? Follow the implementation guide to deploy your MEarnerManager extension.
SVM Implementation: Crank
The Crank extension model is designed for institutional and B2B platforms that require granular control over who can earn yield and how that yield is distributed. It implements a three-tier permissioning hierarchy where an admin delegates authority to Earn Managers, who in turn manage their own sets of earners.
This model is used by Wrapped M ($wM), the first-party M0 extension on Solana, and serves as the blueprint for any protocol serving institutional clients or requiring bespoke yield-sharing agreements.
Source Code: The Crank model is compiled from the m_ext program using the crank Rust feature flag. The source code is available in the solana-m-extensions repository.
Migration Note: In V2, $wM migrated from the legacy ext_earn program to the unified m_ext framework using the crank feature flag.
Architecture and Mechanism
The Crank model implements a sophisticated three-tier hierarchy for yield management:
Three-Tier Hierarchy
-
Admin (Top Level)
- Root authority for the extension
- Can add/remove Earn Managers
- Can set the Earn Authority
- Manages core protocol configuration
-
Earn Managers (Middle Tier)
- Delegated authorities approved by Admin
- Each manager can add/remove their own earners
- Can configure custom fee rates (in basis points)
- Can specify fee recipient token accounts
- Useful for protocols, DAOs, or institutional partners managing their own communities
-
Earners (User Level)
- Individual accounts authorized to earn yield
- Managed by a specific Earn Manager
- Can set custom yield recipient addresses
- Subject to their Earn Manager's fee rate
Roles (Access Control)
admin: The root administrator, typically a secure multi-sig or governance contract.earn_authority: A permissioned key that executes the yield distribution crank (typically an offchain bot).earn_manager: An intermediary authority that can manage a subset of earners and charge fees.earner: An individual account approved to hold the extension token and earn yield.wrap_authority: Addresses authorized to perform wrap/unwrap operations (e.g., swap facility).
How It Works: Crank-Based Yield Distribution
Unlike the ScaledUi model where yield is automatically distributed via rebasing, the Crank model uses an offchain calculation + onchain distribution pattern:
Yield Distribution Flow
Step 1: Yield Accrues in Vault
The extension's m_vault holds $M tokens that are registered as earners in the base $M program. As the $M index updates, the vault's balance grows.
Step 2: Offchain Balance Calculation
The earn_authority (typically an offchain bot) calculates the weighted average balance for each earner since their last claim. This ensures yield is distributed fairly even if balances change during the distribution period.
Step 3: Onchain Claiming (The "Crank")
The earn_authority iterates through eligible earners and calls claim_for(earner, calculated_balance) for each one. This instruction:
- Calculates yield owed based on the snapshot balance
- Deducts the Earn Manager's fee (if configured)
- Mints new extension tokens to the earner's recipient account
- Mints fee tokens to the Earn Manager's fee recipient account
- Updates the earner's
last_indexandlast_balance
Step 4: Continuous Operation
The crank runs periodically (e.g., daily or weekly) to distribute newly accrued yield to all active earners.
Key State Structures
ExtGlobal Account
The top-level configuration for the extension.
pub struct ExtGlobal {
pub admin: Pubkey,
pub earn_authority: Option<Pubkey>,
pub ext_mint: Pubkey,
pub m_mint: Pubkey,
pub m_earn_global_account: Pubkey,
pub bump: u8,
pub m_vault_bump: u8,
pub ext_mint_authority_bump: u8,
pub yield_config: YieldConfig,
pub wrap_authorities: Vec<Pubkey>,
}
pub struct YieldConfig {
pub variant: YieldVariant, // Set to YieldVariant::Crank
pub last_m_index: u64,
pub last_ext_index: u64,
}EarnManager Account
Represents a delegated authority managing a set of earners.
pub struct EarnManager {
pub manager: Pubkey, // The manager's authority
pub fee_bps: u64, // Fee rate in basis points (e.g., 500 = 5%)
pub fee_token_account: Pubkey, // Where manager fees are sent
pub bump: u8,
}Earner Account
Tracks an individual earner's yield distribution state.
pub struct Earner {
pub user: Pubkey, // The earner's wallet
pub user_token_account: Pubkey, // Token account earning yield
pub recipient_token_account: Pubkey, // Where yield is sent (can differ)
pub earn_manager: Pubkey, // The managing EarnManager PDA
pub last_balance: u64, // Balance at last claim
pub last_index: u64, // Index at last claim
pub bump: u8,
}Key Instructions
Admin Instructions
add_earn_manager(manager: Pubkey, fee_bps: u64, fee_token_account: Pubkey): Adds a new Earn Manager with specified fee configuration.remove_earn_manager(manager: Pubkey): Removes an Earn Manager (earners must be removed first).set_earn_authority(new_earn_authority: Option<Pubkey>): Sets or updates the earn authority that can execute claims.
Earn Manager Instructions
add_earner(user: Pubkey, user_token_account: Pubkey, recipient_token_account: Pubkey): Adds a new earner to this manager's set.remove_earner(user: Pubkey): Removes an earner from this manager's set.configure_earn_manager(new_fee_bps: u64, new_fee_token_account: Pubkey): Updates the manager's fee configuration.transfer_earner(user: Pubkey, new_earn_manager: Pubkey): Transfers an earner to a different Earn Manager.
Earn Authority Instructions
claim_for(user: Pubkey, snapshot_balance: u64): Distributes yield to a specific earner based on the calculated snapshot balance.sync(): Updates the extension's index from the base$Mprogram (similar to ScaledUi).
Earner Instructions
set_recipient(new_recipient_token_account: Pubkey): Allows an earner to change where their yield is sent. Useful for DeFi integrations where tokens are locked in a contract.
Wrap/Unwrap
wrap(amount: u64): Wraps$Minto the extension token (requireswrap_authority).unwrap(amount: u64): Unwraps extension token back into$M(requireswrap_authority).
Ready to build? Follow the implementation guide to deploy your Crank extension.

