MGUSD on Stellar
MGUSD is an M0-powered Stellar Asset Contract (SAC) stablecoin. It is minted and burned via a Soroban smart contract (the "Minter Gateway"). The SAC holds balances and handles standard SEP-41 transfers; the minter gateway is the sole authority for supply changes, yield accrual, and compliance controls.
This document is for developers and integrators who want to understand how MGUSD is minted, distributed, redeemed, and how yield accrues. It assumes familiarity with Stellar's account model, trustlines, and the Stellar Asset Contract.
Architecture & Core Components
MGUSD is a two-layer system:
| Component | Description |
|---|---|
| Minter Gateway (Soroban) | The issuance control panel. Holds all roles, the yield index, and the supply accumulators. Every mint, burn, yield claim, rate change, and compliance action goes through it. |
| Stellar Asset Contract (SAC) | The stablecoin token. Holds balances, enforces authorization (AUTH_REQUIRED), and exposes the standard SEP-41 transfer(). The Minter Gateway is the SAC admin and drives it via cross-contract calls. |
The Minter Gateway never exposes a transfer() of its own — holders move tokens using the SAC's standard SEP-41 interface directly. The gateway's job is to be the single, authoritative gateway for everything that changes the supply or compliance state.
Actors
| Actor | Responsibility |
|---|---|
| Bridge | The primary operator — holds Admin, Minter, Yield Recipient Manager, and Forced Transfer Manager, and acts as a block / unblock operator and a pauser. |
| MoneyGram | The Yield Recipient (receives claimed yield) and the off-chain on/off-ramp for end users. |
| M0 | Co-holds the Pauser role. |
| Predicate | Compliance partner — a block / unblock operator for onchain screening. |
Lifecycle Flows
1. Minting (Bridge → Treasury)
- MoneyGram receives fiat from an end user and notifies the Bridge.
- Bridge calls
mint(caller, treasury_address, amount)on the Minter Gateway. - The gateway finalizes pending yield, increases
total_principalandtotal_supply, then mints SAC tokens to the treasury address. - The treasury now holds MGUSD stablecoin.
2. User Distribution (Treasury → End User)
- An unblock operator whitelists accounts — individually via
unblock_user, or in batch viabatch_unblock_users(up to 40 per call). - The treasury transfers tokens to the user via the SAC's standard SEP-41
transfer(). - Whitelisted (unblocked) accounts can freely transfer among themselves.
- Non-whitelisted (blocked) accounts cannot send or receive tokens.
3. Redemption (End User → MoneyGram → Bridge)
- The end user initiates redemption through MoneyGram.
- Bridge calls
burn(caller, user_address, amount)— removing tokens at the SAC layer via clawback. - The Minter Gateway finalizes pending yield and decreases both accumulators.
- MoneyGram sends fiat to the end user off-chain.
4. Yield Claiming
- Bridge calls
set_interest_rate(caller, rate_bps)to set the current interest rate (this is a Minter permission, not Admin). - Yield accrues continuously on
total_principalusing the exponential index. - The Yield Recipient Manager (Bridge) calls
claim_yield(caller)to mint accrued yield as new SAC tokens to the Yield Recipient (MoneyGram). - Claimed yield increases
total_supplybut nottotal_principal— it does not compound.
5. Forced Transfer (Compliance Action)
- The Forced Transfer Manager identifies a need to move tokens between accounts.
- It invokes
force_transfer(caller, from, to, amount)— no authorization from the source account is required. - The Minter Gateway claws back tokens from the source and mints them to the destination at the SAC layer.
- Accumulators are unchanged — this is a balance redistribution, not a supply change.
- Works even if the source account is blocked.
Roles
| Role | Permissions | Held by |
|---|---|---|
| Admin | Role administration only (see below) | Bridge |
| Minter | mint, burn, set_interest_rate | Bridge |
| Yield Recipient Manager | set_yield_recipient, claim_yield | Bridge |
| Yield Recipient | Passive — receives tokens minted by claim_yield | MoneyGram |
| Forced Transfer Manager | force_transfer | Bridge |
| Block operator (membership) | block_user, batch_block_users | Bridge, Predicate |
| Unblock operator (membership) | unblock_user, batch_unblock_users | Bridge, Predicate |
| Pauser (membership) | pause, unpause | M0, Bridge |
set_admin, set_minter, set_yield_recipient_manager, set_forced_transfer_manager, the block / unblock / pauser membership management functions, reconcile_burn, transfer_sac_admin, and upgrade. Admin cannot mint, burn, set the rate, block users, force-transfer, claim yield, or pause without first granting itself the relevant role. There is no implicit emergency fallback — operational procedures must ensure the dedicated role-signers remain reachable.Every role-gated function calls require_auth() on its caller argument and verifies the caller equals the designated role holder — no implicit trust and no admin override. All roles are single-address except Block operator, Unblock operator, and Pauser, each of which is a membership set that Admin grants and revokes.
Contract Interface
Each role can only call its own functions. Admin is not a super-role and cannot call non-admin functions without first granting itself the relevant role.
Admin-exclusive functions
| Function | Signature | Description |
|---|---|---|
set_admin | (new_admin: Address) | Transfer the admin role to a new address |
set_minter | (new_minter: Address) | Set a new minter address |
set_yield_recipient_manager | (new_yrm: Address) | Set a new yield recipient manager |
set_forced_transfer_manager | (new_ftm: Address) | Set a new forced transfer manager |
add_block_operator / remove_block_operator | (addr: Address) | Grant / revoke block permission (membership set; idempotent) |
add_unblock_operator / remove_unblock_operator | (addr: Address) | Grant / revoke unblock permission (membership set; idempotent) |
add_pauser / remove_pauser | (addr: Address) | Grant / revoke pause permission (membership set; idempotent) |
reconcile_burn | (amount: i128) | Decrease both accumulators to reconcile tokens destroyed outside the contract |
transfer_sac_admin | (new_sac_admin: Address) | Transfer SAC admin role from the Minter Gateway to another address |
upgrade | (new_wasm_hash: BytesN<32>) | Upgrade the contract WASM to a new version |
Minter functions
| Function | Signature | Description |
|---|---|---|
mint | (caller: Address, to: Address, amount: i128) | Mint SAC tokens and increase both accumulators |
burn | (caller: Address, from: Address, amount: i128) | Remove SAC tokens (clawback) and decrease both accumulators |
set_interest_rate | (caller: Address, rate_bps: u32) | Set the interest rate in basis points (max 5000 = 50%) |
Forced Transfer Manager functions
| Function | Signature | Description |
|---|---|---|
force_transfer | (caller: Address, from: Address, to: Address, amount: i128) | Force-move SAC tokens between accounts (clawback + mint) |
Yield Recipient Manager functions
| Function | Signature | Description |
|---|---|---|
set_yield_recipient | (caller: Address, new_yr: Address) | Set the address that receives claimed yield |
claim_yield | (caller: Address) -> i128 | Claim accrued yield; mints new SAC tokens to the yield recipient |
Block / unblock (allowlist) functions
block_user / batch_block_users require a block operator; unblock_user / batch_unblock_users require an unblock operator. Backed by the SAC allowlist.
| Function | Signature | Description |
|---|---|---|
block_user | (user: Address, operator: Address) | Block a user on the SAC (set_authorized(false)) |
unblock_user | (user: Address, operator: Address) | Unblock a user on the SAC (set_authorized(true)) |
batch_block_users | (users: Vec<Address>, operator: Address) | Block up to 40 users in a single transaction |
batch_unblock_users | (users: Vec<Address>, operator: Address) | Unblock up to 40 users in a single transaction |
Pauser functions
| Function | Signature | Description |
|---|---|---|
pause | (caller: Address) | Pause the contract — blocks mint, burn, claim_yield, set_interest_rate |
unpause | (caller: Address) | Unpause the contract — resumes all blocked operations |
View functions
admin, minter, yield_recipient_manager, yield_recipient, forced_transfer_manager, is_block_operator, is_unblock_operator, is_pauser, sac_token, interest_rate, current_index, latest_index, accrued_yield, total_principal, total_supply, blocked(account), balance(id), and paused.
Initialization
The contract is initialized via __constructor during deployment. It takes the SAC address plus eight role addresses (admin, minter, yield recipient manager, yield recipient, forced transfer manager, and one seed address each for the block operator, unblock operator, and pauser membership sets). The same address may be reused across arguments when one key should hold multiple permissions. The yield index starts at 1.0 (INDEX_SCALE) on first use.
Yield Mechanics
Continuous index model
Yield accrues continuously using an exponential index:
currentIndex = latestIndex × e^(rate × elapsed / SECONDS_PER_YEAR)
Where latestIndex is the last stored index (initialized to 1.0, scaled as 1e12), rate is the annual rate converted from basis points, elapsed is seconds since the last update, and SECONDS_PER_YEAR is 31,536,000. The e^x term uses a 4th-order Taylor series, accurate for all realistic rates.
Two accumulators
| Accumulator | Tracks | Modified by |
|---|---|---|
total_principal | Yield-earning base (mints − burns) | mint, burn, reconcile_burn |
total_supply | All outstanding tokens (principal + claimed yield) | mint, burn, reconcile_burn, claim_yield |
Only total_principal earns yield. The accrued amount is total_principal × (newIndex − oldIndex) / INDEX_SCALE, accumulated in accrued_yield on every index update.
Non-compounding
When claim_yield() is called, pending yield is finalized, accrued_yield is captured and reset to zero, total_supply increases by the claimed amount, and new SAC tokens are minted to the yield recipient. total_principal is unchanged — claimed yield does not earn more yield.
mint, burn, reconcile_burn, claim_yield, set_interest_rate). This guarantees yield is finalized at the correct principal and rate before any change takes effect.Transfers & the Issuer Burn Problem
Holders move MGUSD using the SAC's standard SEP-41 transfer() — both sender and receiver must be authorized (unblocked) for a transfer to succeed. Transfers happen entirely at the SAC layer and do not change total_principal or total_supply; they are balance redistributions, not mints or burns.
On classic Stellar, sending tokens to the issuer address burns them automatically. If MGUSD is sent to the issuer, tokens are destroyed at the SAC layer but the Minter Gateway's accumulators are never updated — so yield would keep accruing on phantom principal.
Mitigation: AUTH_REQUIRED + whitelist
The SAC is configured with AUTH_REQUIRED, so all accounts start unauthorized (blocked) by default. Accounts can only transact after an unblock operator calls unblock_user(), which limits who can move tokens at all.
AUTH_REQUIRED at the Stellar protocol level. Authorized users can still send tokens directly to the issuer, destroying them without updating the accumulators. The whitelist reduces accidental issuer burns but does not eliminate them. If it happens, Admin calls reconcile_burn(amount) to decrease both accumulators and bring the Minter Gateway's bookkeeping back in line with the actual circulating supply.Compliance & Safety
Block / unblock
The SAC operates in AUTH_REQUIRED mode — accounts are blocked by default. unblock_user maps to SAC set_authorized(true) and block_user to set_authorized(false). Only a block operator can block, and only an unblock operator can unblock. Batch operations (batch_block_users / batch_unblock_users) accept up to 40 users per call, are atomic (any failure reverts the whole transaction), and emit one event per user for indexer compatibility.
Pausable
The Minter Gateway implements a pause mechanism. When paused, the following revert immediately:
| Blocked function | Role |
|---|---|
mint | Minter |
burn | Minter |
set_interest_rate | Minter |
claim_yield | Yield Recipient Manager |
Compliance operations (block_user, unblock_user, their batch variants, and force_transfer) and all view functions remain fully accessible while paused, so regulatory actions can still be executed. reconcile_burn is also intentionally callable while paused — issuer-burn destruction happens outside Minter Gateway control and continues during a pause, so reconciliation must remain available to prevent unbounded accumulator divergence.
Events
Event names are the snake_case form of the underlying contract event struct. Topic fields are marked (topic); the rest are payload data.
| Event | Emitted by | Fields |
|---|---|---|
admin_set | set_admin | old (topic), new |
minter_set | set_minter | old (topic), new |
yield_recipient_manager_set | set_yield_recipient_manager | old (topic), new |
yield_recipient_set | set_yield_recipient | old (topic), new |
forced_transfer_manager_set | set_forced_transfer_manager | old (topic), new |
block_operator_added / block_operator_removed | add_block_operator / remove_block_operator | addr (topic) |
unblock_operator_added / unblock_operator_removed | add_unblock_operator / remove_unblock_operator | addr (topic) |
pauser_added / pauser_removed | add_pauser / remove_pauser | addr (topic) |
interest_rate_set | set_interest_rate | rate_bps |
mint | mint | to (topic), amount, new_total_principal, new_total_supply |
burn | burn | from (topic), amount, new_total_principal, new_total_supply |
reconcile | reconcile_burn | amount, new_total_principal, new_total_supply |
yield_claimed | claim_yield | recipient (topic), amount |
update_index | every supply/rate mutation | latest_index |
force_transfer | force_transfer | from (topic), to (topic), amount |
upgraded | upgrade | by (topic), new_wasm_hash |
sac_admin_transferred | transfer_sac_admin | new_sac_admin (topic) |
user_blocked / user_unblocked | block / unblock functions | user (topic) |
paused / unpaused | pause / unpause | (no fields) |
Onchain Addresses
The MGUSD Minter Gateway and SAC addresses for Stellar Mainnet and Testnet can be found on the MGUSD Deployments page.
Source Code & Audits
- Minter Gateway contract & deployment tooling: m0-platform/mgusd-minter-gateway
- Audits: Security audit reports can be found on the Audits resource page.
Next Steps
- Integrate MGUSD: Use the SAC's standard SEP-41 interface to hold and transfer MGUSD once your account is whitelisted.
- Look up addresses: Find the Minter Gateway and SAC contract addresses on the MGUSD Deployments page.
M0 On Solana
Technical deep dive into the M0 Protocol's implementation on the Solana blockchain, including program specifications, V2 upgrades, and developer integration guides.
Wrapped M (wM)
Complete documentation of Wrapped M (wM), the non-rebasing ERC-20 wrapper that maintains yield-earning capabilities while providing DeFi compatibility.