Portal V2
Overview
Portal V2 is M0's cross-chain bridge and messaging system. It serves two core functions in the M0 ecosystem:
- Token bridging - Transfers M0 extension tokens between all connected chains.
- Protocol state propagation - Synchronizes the
$Mearning index, governance registrar values, and earner lists from Ethereum to all connected chains, and relays fill and cancel reports for the Limit Order Protocol.
Key Design Principles
- Bridge-agnostic core - The Portal has no knowledge of any specific messaging protocol; all delivery is abstracted behind a common
IBridgeAdapterinterface - Per-chain adapter selection - An operator configures a default adapter for each destination chain, and senders can override it per message
- Hub-and-spoke with risk isolation - Ethereum is the hub and single source of truth; each spoke is either isolated (hub-only transfers, with per-spoke principal accounting) or connected (spoke-to-spoke transfers allowed)
- Cross-VM compatibility - Payloads use
bytes32addresses,uint32chain IDs, anduint128amounts so the same encoding works on EVM and non-EVM chains - Permissionless state relay - Anyone can pay to push the latest index, registrar values, or earner Merkle root to a spoke chain
Architecture
Hub Chain (Ethereum)
The HubPortal contract is deployed on Ethereum, where $M is natively issued and governed. It:
- Locks tokens when bridging to spoke chains and releases them when tokens are bridged back
- Tracks bridged principal per isolated spoke, so it can never release more than was locked for that spoke
- Broadcasts the earning index, registrar key-value pairs, registrar list updates, and earner Merkle roots
Spoke Chains
SpokePortal contracts are deployed on every other supported chain. They:
- Mint
$Mwhen tokens arrive from the hub or another spoke, and burn it when tokens are sent away - Apply index and registrar updates received from the hub; if a token transfer carries a newer index, the local index is updated as part of the mint
Bridge Adapters
Bridge adapters are standalone contracts that handle message delivery through a specific cross-chain protocol. The Portal only interacts with the adapter interface:
interface IBridgeAdapter {
/// @notice The Portal this adapter serves.
function portal() external view returns (address);
/// @notice Returns the fee for delivering a message to the destination chain.
function quote(
uint32 destinationChainId,
uint256 gasLimit,
bytes memory payload
) external view returns (uint256 fee);
/// @notice Sends a message to the destination chain.
function sendMessage(
uint32 destinationChainId,
uint256 gasLimit,
bytes32 refundAddress,
bytes memory payload
) external payable;
}
Three adapters are currently implemented:
| Adapter | Protocol | Delivery model |
|---|---|---|
HyperlaneBridgeAdapter | Hyperlane | Mailbox dispatch with ISM validation |
WormholeBridgeAdapter | Wormhole | Core Bridge publication with Executor-based delivery |
LayerZeroBridgeAdapter | LayerZero V2 | Endpoint dispatch with DVN validation |
Each adapter maintains two mappings, configured by its operator:
- Peers (
setPeer()) - the adapter address on each remote chain. Outgoing messages target the peer; incoming messages are only accepted from it. - Chain IDs (
setBridgeChainId()) - a one-to-one mapping between M0's internal chain IDs and the provider's own scheme (Hyperlane domains, LayerZero endpoint IDs, Wormhole chain IDs).
Some adapters take extra arguments through the bridgeAdapterArgs parameter on send functions:
| Adapter | bridgeAdapterArgs |
|---|---|
| Hyperlane | Unused - pass "" |
| LayerZero | Unused - pass "" |
| Wormhole | Required - a signed quote obtained off-chain from the Wormhole Executor API |
Dependencies
SwapFacility- wraps and unwraps$Mextensions. The Portal callsswapOutM()to unwrap an extension before sending andswapInM()to wrap on arrival.MerkleTreeBuilder- deployed on Ethereum; builds Merkle roots from registrar lists (such as the Solana earner list) for propagation to SVM chains.MTokenandRegistrar- the sources of the earning index and governance values that the hub reads at send time.
Chain IDs
Portal V2 uses a single internal chain ID type (uint32) across all contracts and payloads:
- On EVM chains, the internal ID is the standard EVM chain ID (Ethereum
1, Base8453, Arbitrum One42161, and so on). The Portal readsblock.chainiddirectly, so a contentious hard fork cannot be used to replay messages. - Non-EVM chains get assigned custom IDs to avoid collisions - Solana is
1399811149. - Bridge adapters translate internal IDs into provider-specific IDs internally. Callers never handle Wormhole chain IDs, Hyperlane domains, or LayerZero endpoint IDs.
The Limit Order Protocol uses the same internal chain IDs.
Token Transfers
Sending
Users send tokens by calling sendToken() on the Portal contract of the source chain:
function sendToken(
uint256 amount,
address sourceToken, // token to debit on this chain
uint32 destinationChainId,
bytes32 destinationToken, // token to deliver on the destination chain
bytes32 recipient,
bytes32 refundAddress, // receives any bridge fee overpayment
bytes calldata bridgeAdapterArgs
) external payable returns (bytes32 messageId);
A second overload adds an explicit address bridgeAdapter parameter for callers that want a specific adapter instead of the chain's default.
When a transfer is sent:
- The Portal pulls
sourceTokenfrom the caller. If it is an extension rather than$M, the Portal unwraps it through theSwapFacility. - On the hub, the resulting
$Mstays locked in theHubPortaland the bridged principal for the destination spoke is incremented (isolated spokes only). On a spoke, the$Mis burned. - A
TokenTransferpayload is encoded with the amount, the current$Mindex, the destination token, and the recipient. - The payload is handed to the selected bridge adapter along with the full
msg.valueas the delivery fee.
The source and destination token pair must be whitelisted by the operator as a supported bridging path, and the chosen adapter must be registered for the destination chain - otherwise the call reverts with UnsupportedBridgingPath or UnsupportedBridgeAdapter.
Receiving
On the destination chain, the bridge adapter authenticates the incoming message (provider infrastructure plus a peer check) and calls receiveMessage() on its local Portal. Only registered adapters can call it. The Portal then:
- Checks the message ID against already-processed messages to prevent replays
- Releases locked
$M(hub) or mints$M(spoke). On spoke chains a transfer carrying a newer index also advances the local index - If the destination token is an extension, wraps the
$Mthrough theSwapFacilityand delivers it to the recipient
$M instead,
and the Portal emits WrapFailed. Recipients of bridged extension tokens should
be able to handle both the extension and the underlying $M.Bridging Mechanisms
- Hub → Spoke:
$Mis locked on the hub and minted on the spoke. - Spoke → Hub:
$Mis burned on the spoke; theHubPortalverifies the amount does not exceed the bridged principal tracked for that spoke, then releases the locked$M. - Spoke → Spoke:
$Mis burned on the source spoke, minted on the destination spoke - available only between connected spokes.
Isolated and Connected Spokes
Every spoke starts isolated: it can only send tokens to and receive tokens from the hub, and the hub tracks its bridged principal so the spoke can never withdraw more than was bridged to it. Isolation applies only to token transfers - fill and cancel reports can be passed between any two supported chains.
To connect a spoke, the operator calls enableCrossSpokeTokenTransfer() first on the HubPortal for that spoke, and then on each SpokePortal that should accept the new route (a spoke checks that both its own chain and the counterparty are flagged).
Fees and Quoting
Every function that sends a cross-chain message is payable; the delivery fee is paid in the native token via msg.value and forwarded in full to the bridge adapter. Overpayment is refunded by the underlying messaging protocol to refundAddress.
Fees are estimated with a single generic quote function, keyed by payload type (see Message Types):
function quote(uint32 destinationChainId, PayloadType payloadType) external view returns (uint256);
function quote(uint32 destinationChainId, PayloadType payloadType, address bridgeAdapter) external view returns (uint256);
Two provider-specific caveats:
- Wormhole quotes are off-chain. The Wormhole adapter's
quote()always reverts withOnChainQuoteNotSupported- fees are quoted by the Wormhole Executor API, and the resulting signed quote must be passed asbridgeAdapterArgswhen sending. refundAddressmust be a valid address on the refunding chain (the source chain for all currently supported adapters).
Protocol State Propagation
Beyond token transfers, the HubPortal propagates protocol state from Ethereum to spoke chains. All of these functions are permissionless - anyone willing to pay the bridge fee can push the latest values. Each comes in two overloads (default adapter, explicit adapter), mirroring sendToken().
Earning Index
sendMTokenIndex(destinationChainId, refundAddress, bridgeAdapterArgs) broadcasts the current $M earning index so spoke chains accrue yield at the same rate as Ethereum. The index is also embedded in the header of every cross-chain message.
Registrar Data
Governance parameters from M0's TTG governance system are propagated to EVM spoke chains:
- Key-value pairs via
sendRegistrarKey(destinationChainId, key, ...)- reads the current value from the EthereumRegistrarat send time - List status via
sendRegistrarListStatus(destinationChainId, listName, account, ...)- whether an account is on a governance list, such as the approved earners list
Earner Merkle Roots (SVM)
sendEarnersMerkleRoot() sends the Merkle root of the solana-earners registrar list to Solana. The Merkle root is built on Ethereum by the MerkleTreeBuilder contract. Solana accounts then prove their earner status against the root. Only SVM spokes process this message type.
OrderBook Integration
Portal V2 serves as the messaging layer for the Limit Order Protocol:
- Fill reports - when a solver fills a cross-chain order on the destination chain, the
OrderBookcallssendFillReport()to relay the fill back to the origin chain, where the Portal delivers it toreportFill()on the originOrderBook - Cancel reports -
sendCancelReport()relays destination-chain cancellations back to the origin chain for refund processing
Both functions are restricted to the OrderBook contract; they cannot be called by users. Unlike token transfers, reports are not subject to spoke isolation and can travel between any two chains.
Message Types
All cross-chain payloads share an 85-byte header, followed by type-specific fields:
| Header field | Type | Purpose |
|---|---|---|
| Payload type | uint8 | Identifies the message type (see table below) |
| Destination chain ID | uint32 | Internal chain ID of the target chain |
| Destination peer | bytes32 | Adapter address expected on the destination chain |
| Message ID | bytes32 | Unique ID: keccak256(sourceChain, destChain, nonce) |
$M index | uint128 | Current earning index at send time |
Carrying the destination chain and peer in every header lets receiving adapters reject messages delivered to the wrong chain or contract - important for protocols like Wormhole where a published message is observable by every chain.
| Type | Name | Direction | Payload fields beyond the header |
|---|---|---|---|
| 0 | TokenTransfer | Hub ↔ Spoke, Spoke ↔ Spoke | amount, destinationToken, sender, recipient |
| 1 | Index | Hub → Spoke | none - the header index is the payload |
| 2 | RegistrarKey | Hub → Spoke | key, value |
| 3 | RegistrarList | Hub → Spoke (EVM) | listName, account, add |
| 4 | FillReport | Any → Any | orderId, amountInToRelease, amountOutFilled, originRecipient, tokenIn |
| 5 | EarnerMerkleRoot | Hub → Spoke (SVM) | earnerMerkleRoot |
| 6 | CancelReport | Any → Any | orderId, orderSender, tokenIn, amountInToRefund |
Payloads are packed with exact lengths - a message with trailing or missing bytes fails to decode.
Roles and Permissions
The Portal and every bridge adapter are UUPS-upgradeable proxies using OpenZeppelin role-based access control.
Portal Roles
| Role | Capabilities |
|---|---|
Admin (DEFAULT_ADMIN_ROLE) | Authorize contract upgrades; grant and revoke all roles |
Pauser (PAUSER_ROLE) | pauseSend(), pauseReceive(), pauseAll() and the corresponding unpause functions |
Operator (OPERATOR_ROLE) | setDefaultBridgeAdapter(), setSupportedBridgeAdapter(), setSupportedBridgingPath(), setPayloadGasLimit(), enableCrossSpokeTokenTransfer() |
Bridge Adapter Roles
| Role | Capabilities |
|---|---|
Admin (DEFAULT_ADMIN_ROLE) | Authorize adapter upgrades; grant and revoke roles |
Operator (OPERATOR_ROLE) | setPeer(), setBridgeChainId(); LayerZero: setDelegate(); Wormhole: setSenderPeer(), setMsgValue() |
Restricted and Permissionless Calls
- Only the
OrderBookcontract can callsendFillReport()andsendCancelReport() - Only registered bridge adapters can call
receiveMessage()on the Portal sendToken(),sendMTokenIndex(),sendRegistrarKey(),sendRegistrarListStatus(),sendEarnersMerkleRoot(),enableEarning(), anddisableEarning()are permissionless
Security
- Adapter authentication - Each adapter accepts inbound messages only from its provider's verified infrastructure (Hyperlane Mailbox, LayerZero Endpoint, valid Wormhole VAAs) and only from the registered peer on the source chain
- Replay protection - Message IDs are recorded on the receiving Portal and duplicate deliveries revert; the Wormhole adapter additionally tracks consumed VAA hashes
- Wrong-destination protection - The destination chain ID and adapter address in every payload header are validated on receipt, so a message published for one chain cannot be executed on another
- Independent pause controls - Sending and receiving can be paused separately, allowing partial operation during incidents
- Per-spoke principal accounting - For isolated spokes, the
HubPortalreverts withInsufficientBridgedBalancerather than releasing more$Mthan was bridged to that spoke - Reentrancy protection - A transient-storage lock guards all send and receive paths
- Wrap failure fallback - A failing extension wrap on delivery degrades to a plain
$Mtransfer instead of blocking the message
Audit reports for Portal V2 are listed on the audits page.
Contract Reference
The source code for Portal V2 can be found in the m-portal-v2 repository. Deployed addresses for every chain are listed in the deployments reference.
Key Functions
| Function | Contract | Description |
|---|---|---|
sendToken() | HubPortal, SpokePortal | Bridge tokens to another chain (2 overloads) |
quote() | HubPortal, SpokePortal | Estimate the bridge fee for a payload type (2 overloads) |
receiveMessage() | HubPortal, SpokePortal | Deliver an inbound message (bridge adapters only) |
sendMTokenIndex() | HubPortal | Broadcast the earning index (2 overloads) |
sendRegistrarKey() | HubPortal | Propagate a registrar key-value pair (2 overloads) |
sendRegistrarListStatus() | HubPortal | Propagate an account's list membership (2 overloads) |
sendEarnersMerkleRoot() | HubPortal | Broadcast the SVM earner Merkle root (2 overloads) |
sendFillReport() / sendCancelReport() | HubPortal, SpokePortal | Relay OrderBook reports (OrderBook only, 2 overloads each) |
enableEarning() / disableEarning() | HubPortal | Toggle portal earning (permissionless; disable is permanent) |
enableCrossSpokeTokenTransfer() | HubPortal, SpokePortal | Connect a spoke for spoke-to-spoke transfers (OPERATOR_ROLE) |
pauseSend() / pauseReceive() / pauseAll() | HubPortal, SpokePortal | Pause message flow independently (PAUSER_ROLE) |
View Functions
| Function | Description |
|---|---|
defaultBridgeAdapter(chainId) | Default adapter configured for a destination chain |
supportedBridgeAdapter(chainId, adapter) | Whether an adapter is registered for a destination |
supportedBridgingPath(sourceToken, chainId, destinationToken) | Whether a token pair is bridgeable |
payloadGasLimit(chainId, payloadType) | Configured destination gas limit per payload type |
currentChainId() | This chain's internal (EVM) chain ID |
currentIndex() | The $M index the Portal would attach to a message right now |
bridgedPrincipal(spokeChainId) | Hub only - principal tracked for an isolated spoke |
crossSpokeTokenTransferEnabled(chainId) | Whether a spoke is connected |
hubChainId() | Spoke only - internal chain ID of the hub |
mToken() / registrar() / swapFacility() / orderBook() | Linked protocol contracts |
Events
// Token bridging
event TokenSent(
address indexed sourceToken,
uint32 destinationChainId,
bytes32 destinationToken,
address indexed sender,
bytes32 indexed recipient,
uint256 amount,
uint128 index,
address bridgeAdapter,
bytes32 messageId
);
event TokenReceived(
uint32 sourceChainId,
address indexed destinationToken,
bytes32 indexed sender,
address indexed recipient,
uint256 amount,
uint128 index,
bytes32 messageId
);
event WrapFailed(address indexed destinationExtension, address indexed recipient, uint256 amount);
// State propagation (HubPortal)
event MTokenIndexSent(uint32 indexed destinationChainId, uint128 index, address bridgeAdapter, bytes32 messageId);
event RegistrarKeySent(uint32 indexed destinationChainId, bytes32 indexed key, bytes32 value, uint128 index, address bridgeAdapter, bytes32 messageId);
event RegistrarListStatusSent(uint32 indexed destinationChainId, bytes32 indexed listName, address indexed account, bool status, uint128 index, address bridgeAdapter, bytes32 messageId);
event EarnerMerkleRootSent(uint32 indexed destinationChainId, uint128 index, bytes32 earnerMerkleRoot, address bridgeAdapter, bytes32 messageId);
event EarningEnabled(uint128 index);
event EarningDisabled(uint128 index);
// State application (SpokePortal)
event MTokenIndexReceived(uint128 index, bytes32 messageId);
event RegistrarKeyReceived(bytes32 indexed key, bytes32 value, uint128 index, bytes32 messageId);
event RegistrarListUpdateReceived(bytes32 indexed listName, address indexed account, bool add, uint128 index, bytes32 messageId);
Error Codes
| Error | Description |
|---|---|
UnsupportedBridgingPath | The source/destination token pair is not whitelisted for that chain |
UnsupportedBridgeAdapter | The adapter is not registered for the destination chain |
PayloadGasLimitNotSet | No destination gas limit configured for this payload type |
ZeroAmount / ZeroRecipient / ZeroRefundAddress | Required parameter is zero |
InsufficientAmountReceived | The Portal received less $M than the specified send amount |
InsufficientBridgedBalance | Release would exceed the principal bridged to an isolated spoke |
CrossSpokeTokenTransferDisabled | Spoke-to-spoke transfer attempted on an isolated route |
MessageAlreadyProcessed | Replay attempt - this message ID was already delivered |
SendingPaused / ReceivingPaused | The corresponding direction is paused |
NotBridgeAdapter | receiveMessage() called by an unregistered address |
NotOrderBook | Fill/cancel report sent by an address other than the OrderBook |
EarningCannotBeReenabled | Earning was disabled and is permanently off |
UnsupportedChain / UnsupportedSender | Adapter-level: unknown chain mapping or wrong remote peer |
OnChainQuoteNotSupported | Wormhole adapter: use the Executor API for quotes |
InvalidVaa | Wormhole adapter: VAA failed Core Bridge verification |
Related
- Bridging M and wM tokens - Step-by-step user guide with a block explorer
- Integrating with Portal V2 - Developer guide with contract calls and a code example
- Limit Order Protocol - The settlement layer that relies on Portal V2 messaging
- Accessing Liquidity - Overview of M0's liquidity infrastructure
- Deployments - Contract addresses on every supported chain
- Source Code - Smart contract repository
Minting & Burning (MinterGateway)
Complete documentation of the MinterGateway contract, the central hub for minting and burning, managing minter collateral, and tracking debt obligations.
PYUSDx Specification
Low-level reference for PYUSDx: token parameters, roles, initialization parameters, rounding matrices, invariants, the cross-chain message format, errors, and events.