PROTOCOL DETAILS

Portal V2

M0's bridging infrastructure: hub-and-spoke token transfers, pluggable bridge adapters, and protocol state propagation.

Overview

Portal V2 is M0's cross-chain bridge and messaging system. It serves two core functions in the M0 ecosystem:

  1. Token bridging - Transfers M0 extension tokens between all connected chains.
  2. Protocol state propagation - Synchronizes the $M earning 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 IBridgeAdapter interface
  • 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 bytes32 addresses, uint32 chain IDs, and uint128 amounts 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 $M when 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:

AdapterProtocolDelivery model
HyperlaneBridgeAdapterHyperlaneMailbox dispatch with ISM validation
WormholeBridgeAdapterWormholeCore Bridge publication with Executor-based delivery
LayerZeroBridgeAdapterLayerZero V2Endpoint 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:

AdapterbridgeAdapterArgs
HyperlaneUnused - pass ""
LayerZeroUnused - pass ""
WormholeRequired - a signed quote obtained off-chain from the Wormhole Executor API

Dependencies

  • SwapFacility - wraps and unwraps $M extensions. The Portal calls swapOutM() to unwrap an extension before sending and swapInM() 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.
  • MToken and Registrar - 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, Base 8453, Arbitrum One 42161, and so on). The Portal reads block.chainid directly, 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:

  1. The Portal pulls sourceToken from the caller. If it is an extension rather than $M, the Portal unwraps it through the SwapFacility.
  2. On the hub, the resulting $M stays locked in the HubPortal and the bridged principal for the destination spoke is incremented (isolated spokes only). On a spoke, the $M is burned.
  3. A TokenTransfer payload is encoded with the amount, the current $M index, the destination token, and the recipient.
  4. The payload is handed to the selected bridge adapter along with the full msg.value as 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:

  1. Checks the message ID against already-processed messages to prevent replays
  2. Releases locked $M (hub) or mints $M (spoke). On spoke chains a transfer carrying a newer index also advances the local index
  3. If the destination token is an extension, wraps the $M through the SwapFacility and delivers it to the recipient
Wrap failure fallback. If wrapping to the destination extension fails for any reason, the transfer does not revert - the recipient receives plain $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: $M is locked on the hub and minted on the spoke.
  • Spoke → Hub: $M is burned on the spoke; the HubPortal verifies the amount does not exceed the bridged principal tracked for that spoke, then releases the locked $M.
  • Spoke → Spoke: $M is 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).

Connecting a spoke is effectively irreversible. Once cross-spoke transfers are enabled, the hub stops tracking bridged principal for that chain - it is reset to zero, since token flows between spokes can no longer be fully accounted for on-chain.

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 with OnChainQuoteNotSupported - fees are quoted by the Wormhole Executor API, and the resulting signed quote must be passed as bridgeAdapterArgs when sending.
  • refundAddress must 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 Ethereum Registrar at 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.

Message ordering is not guaranteed. Cross-chain messaging protocols do not guarantee delivery order, so an older registrar value can occasionally arrive after - and overwrite - a newer one. Portal V2 deliberately omits ordering logic; since all state-propagation functions are permissionless, the correct value can simply be re-sent.

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 OrderBook calls sendFillReport() to relay the fill back to the origin chain, where the Portal delivers it to reportFill() on the origin OrderBook
  • 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 fieldTypePurpose
Payload typeuint8Identifies the message type (see table below)
Destination chain IDuint32Internal chain ID of the target chain
Destination peerbytes32Adapter address expected on the destination chain
Message IDbytes32Unique ID: keccak256(sourceChain, destChain, nonce)
$M indexuint128Current 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.

TypeNameDirectionPayload fields beyond the header
0TokenTransferHub ↔ Spoke, Spoke ↔ Spokeamount, destinationToken, sender, recipient
1IndexHub → Spokenone - the header index is the payload
2RegistrarKeyHub → Spokekey, value
3RegistrarListHub → Spoke (EVM)listName, account, add
4FillReportAny → AnyorderId, amountInToRelease, amountOutFilled, originRecipient, tokenIn
5EarnerMerkleRootHub → Spoke (SVM)earnerMerkleRoot
6CancelReportAny → AnyorderId, 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

RoleCapabilities
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

RoleCapabilities
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 OrderBook contract can call sendFillReport() and sendCancelReport()
  • Only registered bridge adapters can call receiveMessage() on the Portal
  • sendToken(), sendMTokenIndex(), sendRegistrarKey(), sendRegistrarListStatus(), sendEarnersMerkleRoot(), enableEarning(), and disableEarning() 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 HubPortal reverts with InsufficientBridgedBalance rather than releasing more $M than 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 $M transfer 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

FunctionContractDescription
sendToken()HubPortal, SpokePortalBridge tokens to another chain (2 overloads)
quote()HubPortal, SpokePortalEstimate the bridge fee for a payload type (2 overloads)
receiveMessage()HubPortal, SpokePortalDeliver an inbound message (bridge adapters only)
sendMTokenIndex()HubPortalBroadcast the earning index (2 overloads)
sendRegistrarKey()HubPortalPropagate a registrar key-value pair (2 overloads)
sendRegistrarListStatus()HubPortalPropagate an account's list membership (2 overloads)
sendEarnersMerkleRoot()HubPortalBroadcast the SVM earner Merkle root (2 overloads)
sendFillReport() / sendCancelReport()HubPortal, SpokePortalRelay OrderBook reports (OrderBook only, 2 overloads each)
enableEarning() / disableEarning()HubPortalToggle portal earning (permissionless; disable is permanent)
enableCrossSpokeTokenTransfer()HubPortal, SpokePortalConnect a spoke for spoke-to-spoke transfers (OPERATOR_ROLE)
pauseSend() / pauseReceive() / pauseAll()HubPortal, SpokePortalPause message flow independently (PAUSER_ROLE)

View Functions

FunctionDescription
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

ErrorDescription
UnsupportedBridgingPathThe source/destination token pair is not whitelisted for that chain
UnsupportedBridgeAdapterThe adapter is not registered for the destination chain
PayloadGasLimitNotSetNo destination gas limit configured for this payload type
ZeroAmount / ZeroRecipient / ZeroRefundAddressRequired parameter is zero
InsufficientAmountReceivedThe Portal received less $M than the specified send amount
InsufficientBridgedBalanceRelease would exceed the principal bridged to an isolated spoke
CrossSpokeTokenTransferDisabledSpoke-to-spoke transfer attempted on an isolated route
MessageAlreadyProcessedReplay attempt - this message ID was already delivered
SendingPaused / ReceivingPausedThe corresponding direction is paused
NotBridgeAdapterreceiveMessage() called by an unregistered address
NotOrderBookFill/cancel report sent by an address other than the OrderBook
EarningCannotBeReenabledEarning was disabled and is permanently off
UnsupportedChain / UnsupportedSenderAdapter-level: unknown chain mapping or wrong remote peer
OnChainQuoteNotSupportedWormhole adapter: use the Executor API for quotes
InvalidVaaWormhole adapter: VAA failed Core Bridge verification
Copyright © M0 Foundation 2026