Integrating with Portal V2
This guide is for developers integrating with Portal V2, M0's unified cross-chain bridge. Portal V2 replaces the previous two-protocol system (Portal Standard on Wormhole NTT and Portal Lite on Hyperlane) with a single contract deployed on every chain and a pluggable bridge adapter layer.
- Source code: m-portal-v2 on GitHub
- Architecture: Portal V2
Key concepts
- One contract per chain.
HubPortalon Ethereum locks and releases tokens.SpokePortalon every other chain mints and burns them. You always call the Portal on the source chain. - Unified flow. The same
quote,approve,sendTokensequence works hub to spoke, spoke to hub, and spoke to spoke (where cross-spoke transfers are enabled). - Standard EVM chain IDs.
destinationChainIdis the destination chain's standard EVM chain ID (uint32). The bridge adapter maps it to the provider-specific ID internally. There are no Wormhole chain IDs in Portal V2. bytes32addressing.recipient,destinationToken, andrefundAddressarebytes32. Left-pad a 20-byte EVM address to 32 bytes (for example, viem'spad). Solana addresses are already 32 bytes.- Fee as
msg.value. Cross-chain delivery costs a fee. Read it withquoteand pass it as the payable value onsendToken. Excess is returned torefundAddress. - Pluggable adapters. Each destination chain has a default bridge adapter (Wormhole, Hyperlane, or LayerZero). You can optionally specify an explicit adapter using the overloaded functions.
- Wrap-failure fallback. If the destination token is an extension and wrapping fails, the recipient receives the underlying
$Mtoken instead of the transaction reverting.
sendToken handles $M, Wrapped $M, and M0 extension tokens. Set sourceToken and destinationToken to whichever pair you are bridging.Supported chains
Portal V2 is live on Ethereum (the hub) and the spokes below. Pass the destination's standard EVM chain ID as destinationChainId; the adapter maps it to the provider ID internally.
| Chain | EVM chain ID |
|---|---|
| Ethereum (hub) | 1 |
| Arbitrum | 42161 |
| Base | 8453 |
| Linea | 59144 |
| BNB | 56 |
| HyperEVM | 999 |
| Plume | 98866 |
| Mantra | 5888 |
| Soneium | 1868 |
| Plasma | 9745 |
| Citrea | 4114 |
| 0G | 16661 |
| Fluent | 25363 |
| Moca | 2288 |
| Monad | 143 |
| Rise | 4153 |
Read the per-chain HubPortal / SpokePortal and token addresses from the Deployments page. Solana is a non-EVM (SVM) chain; it is a supported spoke but uses SVM addressing, so see the Solana docs for SVM-specific integration.
Addresses
Do not hard-code Portal or token addresses. Read the current HubPortal, SpokePortal, $M, and w$M addresses from the Deployments page.
Contract interface
quote
Returns the fee (in the source chain's native currency) for delivering a message to the destination chain. Pass this value as msg.value on the sending call.
function quote(
uint32 destinationChainId,
PayloadType payloadType
) external view returns (uint256);
// Overload to quote a specific bridge adapter:
function quote(
uint32 destinationChainId,
PayloadType payloadType,
address bridgeAdapter
) external view returns (uint256);
payloadType is 0 for a token transfer (TokenTransfer). The other payload types (Index, RegistrarKey, RegistrarList, FillReport, EarnerMerkleRoot, CancelReport) are used for protocol metadata and OrderBook messages, not user token bridging.
sendToken
Locks or burns the source token and sends a cross-chain message that mints or releases the destination token to recipient.
function sendToken(
uint256 amount,
address sourceToken,
uint32 destinationChainId,
bytes32 destinationToken,
bytes32 recipient,
bytes32 refundAddress,
bytes calldata bridgeAdapterArgs
) external payable returns (bytes32 messageId);
// Overload to route through a specific bridge adapter:
function sendToken(
uint256 amount,
address sourceToken,
uint32 destinationChainId,
bytes32 destinationToken,
bytes32 recipient,
bytes32 refundAddress,
address bridgeAdapter,
bytes calldata bridgeAdapterArgs
) external payable returns (bytes32 messageId);
Parameters:
amount: token amount in base units ($Mandw$Muse 6 decimals).sourceToken: the token address on the source chain ($M,w$M, or an extension).destinationChainId: the destination chain's standard EVM chain ID.destinationToken: thebytes32token address to receive on the destination chain.recipient: thebytes32address that receives the tokens.refundAddress: thebytes32address that receives any excess native fee.bridgeAdapter(overload only): an explicit bridge adapter address. Omit to use the chain's default adapter.bridgeAdapterArgs: optional adapter arguments; pass empty bytes (0x) for default behavior.
The first overload uses the default bridge adapter configured for the destination chain, which is what most integrations should use.
Integration flow
The sequence is identical in every direction. Always target the Portal on the source chain (HubPortal on Ethereum, SpokePortal elsewhere).
Step 1: Quote the fee
Call quote(destinationChainId, 0) on the source-chain Portal to get the native fee for a token transfer.
Step 2: Approve the Portal
Call approve(portal, amount) on the source token so the Portal can pull amount.
Step 3: Send the token
Call sendToken(...) on the source-chain Portal, passing the quoted fee as msg.value and the bytes32-encoded destinationToken, recipient, and refundAddress.
Step 4: Track delivery
sendToken returns a messageId and emits a TokenSent event. The bridge adapter relays the message and the destination Portal emits TokenReceived on arrival.
TypeScript example (viem)
This example bridges $M from Ethereum to Arbitrum using viem. Read the actual HubPortal and $M addresses from the Deployments page.
import {
createPublicClient,
createWalletClient,
http,
pad,
parseAbi
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { mainnet } from 'viem/chains'
// Addresses: read from /resources/addresses/m0-platform
const HUB_PORTAL = '0x...' as const // HubPortal on Ethereum
const M_TOKEN = '0x...' as const // $M on Ethereum
const M_TOKEN_ARBITRUM = '0x...' as const // $M on Arbitrum
const ARBITRUM_CHAIN_ID = 42161 // standard EVM chain ID
const PAYLOAD_TYPE_TOKEN_TRANSFER = 0
const portalAbi = parseAbi([
'function quote(uint32 destinationChainId, uint8 payloadType) view returns (uint256)',
'function sendToken(uint256 amount, address sourceToken, uint32 destinationChainId, bytes32 destinationToken, bytes32 recipient, bytes32 refundAddress, bytes bridgeAdapterArgs) payable returns (bytes32)'
])
const erc20Abi = parseAbi([
'function approve(address spender, uint256 amount) returns (bool)'
])
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
const publicClient = createPublicClient({ chain: mainnet, transport: http() })
const walletClient = createWalletClient({
account,
chain: mainnet,
transport: http()
})
async function bridgeMToArbitrum(amount: bigint) {
// 1. Quote the messaging fee (native value to send with sendToken)
const fee = await publicClient.readContract({
address: HUB_PORTAL,
abi: portalAbi,
functionName: 'quote',
args: [ARBITRUM_CHAIN_ID, PAYLOAD_TYPE_TOKEN_TRANSFER]
})
// 2. Approve the Portal to pull the tokens
const approveHash = await walletClient.writeContract({
address: M_TOKEN,
abi: erc20Abi,
functionName: 'approve',
args: [HUB_PORTAL, amount]
})
await publicClient.waitForTransactionReceipt({ hash: approveHash })
// 3. bytes32-encode the destination token, recipient, and refund address
const destinationToken = pad(M_TOKEN_ARBITRUM)
const recipient = pad(account.address)
const refundAddress = pad(account.address)
// 4. Send the token, passing the quoted fee as msg.value
const sendHash = await walletClient.writeContract({
address: HUB_PORTAL,
abi: portalAbi,
functionName: 'sendToken',
args: [
amount,
M_TOKEN,
ARBITRUM_CHAIN_ID,
destinationToken,
recipient,
refundAddress,
'0x' // empty bridgeAdapterArgs: use the default adapter
],
value: fee
})
return publicClient.waitForTransactionReceipt({ hash: sendHash })
}
To bridge in the reverse direction (Arbitrum to Ethereum) or between spokes, point the clients at the source chain, use that chain's SpokePortal and token addresses, and set destinationChainId to the destination chain's standard EVM chain ID (for example, 1 for Ethereum). The call sequence does not change.
Related
- Portal V2 - Architecture, message types, roles, and security model.
- Deployments - Current Portal and token addresses per chain.
- Bridging M and wM Tokens - Manual walkthrough using a block explorer.
- Solana - Bridging to and from Solana.