This ERC proposes a mailbox API and message format for sending and receiving data between L2s. This standard makes it easier for developers to build cross-chain applications that work over a variety of VMs, chain settlement mechanisms (e.g., ZK or optimistic settlement), and messaging protocols (e.g., synchronous or asynchronous protocols). This ERC accomplishes this by 1.) defining a mailbox interface through which cross-chain messages can be sent and received independent of their payload; 2.) defining a VM-agnostic message format and address type to make the interface compatible with many VMs; 3.) keeping the mailbox interface minimal to make it compatible with many kinds of cross-chain communication. Example applications include an intent-based bridge, a liquidity-unifying DEX operating across multiple chains, or a cross-chain lending application.
L2s have scaled Ethereum and unlocked new avenues for innovation, but left the ecosystem fragmented. To address this, there are a variety of cross-chain communication protocols designed to make L2s composable with each other, each implements its own message format that is incompatible with others. This ERC proposes a neutral, standard format for sending and receiving cross-chain messages. By standardizing the interface chains for messaging, we achieve:
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
There is a wide range of ways in which both synchronous and asynchronous protocols may operate. For example, some protocols (particularly for asynchronous messaging) may require the source chain's block in which the message was sent to be finalized (e.g., settled on Ethereum) before it can be included within a block produced for the destination chain. Other protocols may allow for messages to be optimistically included in blocks with consistency checks delayed until settlement time (reminiscent of speculative execution).
All messages MUST follow this format:
/// @title Metadata type
/// @notice Metadata for a cross-chain message
struct Metadata {
/// @notice The chain identifier of the source chain
uint32 srcChainId;
/// @notice The chain identifier of the destination chain
uint32 destChainId;
/// @notice The address of the sending party
/// @dev 32 bytes are used to encode the address. In the case of an
/// Ethereum address, the last 12 bytes can be padded with zeros
bytes32 srcAddress;
/// @notice The address of the recipient
/// @dev 32 bytes are used to encode the address. In the case of an
/// Ethereum address, the last 12 bytes can be padded with zeros
bytes32 destAddress;
/// @notice The identifier for a cross-chain interaction session
/// @dev SHOULD be unique for every new cross-chain calls
uint128 sessionId;
/// @notice The message counter within an interaction session
/// @dev SHOULD be unique within a session
/// @dev OPTIONAL for most asynchronous bridges where every message has a
/// distinct sessionId, simply set to 0 if unused
/// @dev E.g. In a cross-chain call: ChainA.func1 -m1-> ChainB.func2 -m2->
/// ChainC.func3 -m3-> ChainB.func4, the subscript i in m_i is the nonce
uint128 nonce;
}
/// @title Message type
/// @notice A cross-chain message
struct Message {
/// @notice The message metadata
Metadata metadata;
/// @notice Message payload
/// @dev It may be ABI-encoded function calls, info about bridged assets,
/// or arbitrary message data
bytes payload;
}
Implementations SHOULD use a global rollup registry service that supports registration, deregistration, and efficient lookup of a rollup's chain ID. This work is outside the scope of this ERC, however.
Our standard is compatible with any ERC defining chain-specific addresses to better display the sender and receiver of a Message
Each chain SHOULD have two canonical Mailbox contracts, one for synchronous and the other for asynchronous messaging, responsible for managing both incoming and outgoing messages. The following APIs are RECOMMENDED to provide the minimal required functionality. Implementations of these APIs MAY include additional functions to support customized or more complex workflows.
/// @title Mailbox contract.
/// @notice Mailbox for sending (resp. receiving) messages to (resp. from)
/// other chains, standardized for messaging protocols that support
/// synchronous or asynchronous, or both types of message passing.
interface Mailbox {
// @notice Inbox: a key-value map, mapping: metadata digest -> payload
// @dev Implementators MAY instantiate with the following map
// mapping(bytes32 => bytes) inbox;
/// @notice Returns the chain ID for the host chain
/// @dev SHOULD be set at the deployment time as immutable except for when
/// using an upgradable Mailbox since immutable variables are discouraged.
/// @dev MUST NOT change regardless of upgradable contracts or not.
function chain_id() virtual public view returns (uint32);
/// @notice Returns the digest of the inbox, used for mailbox consistency
/// checks
/// @dev There SHOULD be an accumulator (e.g. chained-hash or MerkleTree)
/// logic that takes in every new inbox message and updates the digest
/// @param srcChainId Identifier of the source chain
/// @return Digest of all inbox messages coming from `srcChainId`
function inboxDigest(uint32 srcChainId) virtual public returns (bytes32);
/// @notice Returns the digest of the outbox, used for mailbox consistency
/// checks
/// @dev There SHOULD be an accumulator (e.g. chained-hash or MerkleTree)
/// logic that takes in every new outbox message and updates the digest
/// @param destChainId Identifier of the destination chain
/// @return Digest of all outbox messages directed at `destChainId`
function outboxDigest(uint32 destChainId) public returns (bytes32);
/// @notice Returns the "key" in inbox/outbox map for a message according
/// to its metadata
/// @dev The metadata includes all fields in the `Metadata` struct.
function getMetadataDigest(
Metadata calldata metadata
) virtual public pure returns (bytes32);
/// @notice Send a message to another chain
/// @param metadata Metadata of the message
/// @param payload Payload of the message
/// @dev SHOULD sanity check `metadata.srcChainId == this.chain_id() &&
/// metadata.srcAddress == msg.sender`;
/// @dev SHOULD update the outbox digest and/or the outbox
function send(Metadata calldata metadata, bytes memory payload) virtual public;
/// @notice Receive a message from another chain
/// @dev SHOULD revert if message cannot be retrieved
/// @dev SHOULD sanity check `metadata.destChainId == this.chain_id()`
/// @param metadata Metadata of the message
/// @return payload of the retrieved message
function recv(
Metadata calldata metadata
) virtual public returns (bytes memory payload);
/// @notice Populate the inbox with incoming messages
/// @param messages Inbox messages to put in `this.inbox`
/// @param aux OPTIONAL auxiliary information/witness to justify these
/// inbox messages
/// @dev `aux` may be empty or signature from a trusted relayer, etc.
function populateInbox(
Message[] calldata messages,
bytes memory aux
) virtual public;
/// @notice Generates a fresh and random sessionId for new messages
/// @dev In order to ensure the uniqueness of the value generated, this
/// function MIGHT require using a contract variable
/// @dev With this unique session ID, for messages that do not require a
/// nonce, we can set nonce=0, and the overall metadata digest is still
/// collision-free with high probability
/// @return A unique sessionId
function randSessionId() virtual public returns (uint128);
}
The Mailbox contract SHOULD keep track of an inbox of incoming messages. The concrete data structure used to store the inbox queue SHOULD be a hash-map-like mapping in Solidity to enable efficient lookup by the dApps with payload-independent query keys. Being able to read/receive messages based on their metadata only, rather than their actual payload, is critical in achieving synchronous messaging with a dynamic payload known only at runtime. It is OPTIONAL to track the full outbox messages in the contract storage — an outbox digest may be sufficient for some cases.
While a single Mailbox contract could support both synchronous and asynchronous messaging protocols, this would require applications to specify the messaging mode for each interaction since each mode may require different settlement logic. Such a design would significantly complicate the Mailbox contract API, its implementation, and related infrastructure components like settlement logic. A more practical approach is to deploy separate, canonical Mailbox contracts for each mode. This allows applications to switch between synchronous and asynchronous messaging simply by pointing to the appropriate contract address.
Messages received via Mailbox.recv() are authenticated because they must first be populated in the inbox, with their integrity verified before the receiving action finalizes. This verification is performed either through the aux field during the populateInbox() call or via an external settlement layer.
Notice that our standard requires no changes to a chain's VM (i.e. no new opcode or precompiles required), but only proposes some smart contract interfaces. Specifically, the sender/recipient account type — generic bytes32 instead of EVM-specific address type -- allows this standard to support a much wider class of VMs (e.g. SolanaVM that uses Ed25519 public key as their accounts).
An optional MultiplexABIEncoder contract can abstract away the VM-specific encoding when preparing the message payload: a generic encode(chainId) function, as opposed to EVM’s abi.encode, that takes in the destination chain ID and decides the corresponding encoder logic so that the receiving party can decode natively. If the apps only care about interoperating with EVM chains, they can safely use abi.encode() as is and not go through any general encoder.
On the receiving side, EVM chains would type cast address(parsedAddress) on the bytes32 parsedAddress from the received message.
The Message.payload field is designed for maximum flexibility, capable of encoding arbitrary data, including application-specific structures like the CrossChainOrder from ERC-7683 (See Example Usage section below for details of this integration). In contrast to some existing bridge designs that restrict the payload to function calls, the message payload in our design can also represent simpler data types, such as a boolean status flag for acknowledgments. This flexibility enables a wider range of use cases and simplifies integration across various applications.
sessionId for Message Query KeyFor message lookups in the mailbox, the query key is derived from the hash of all message metadata rather than relying on a single sessionId field. This is because the sessionId derivation is customizable and may not adequately bind to key metadata like source and destination addresses. In contrast, the wrapping messaging protocol may enforce permissions for sending or receiving based on these metadata fields, so the query key must bind to the entire set of metadata.
Observe that when applications allow users to define sessionId, these values may not be unique across messages. Depending on the implementation of the inbox, this could be a concern. For example, a mapping-based inbox needs an additional nullifier set to enforce the uniqueness of message metadata and avoid message overwrites in the case of a colliding map-key.
In contrast to other asynchronous bridge designs, our standard explicitly separates inbox filling from reading, enabling a unified interface for message retrieval. This separation allows specialized parties like builders or coordinators to handle protocol-specific message authentication when writing to the inbox, while applications can fetch messages directly. Additionally, message retrieval requires only metadata rather than the complete message, which is valuable in synchronous settings where message payloads are determined at execution time.
As mentioned above, pre-filling the inbox of the destination chain incurs additional gas costs which are manageable on L2s. We list some advantages of our approach:
populateInbox(), and delay the mailbox check to the settlement layer.populateInbox()and/or the settlement layer logic) without requiring changes to connected apps.msg.sender. Detailed explanations are omitted for brevity (extended answers on the website).In comparison to Inter-blockchain Communication(IBC)-like standards, this ERC is designed to work in a stateless manner. Messages do not need to pass a proof from the source chain at the time they are consumed on the destination chain. This allows use cases such as synchronous composability and intra-block messaging since messages don’t need to include finalized state from the source chain. Additionally, this ERC does not require multiple steps to establish a link between two chains. Messages can be directly sent from one chain to another in a single step.
ERC-7683 standardizes intent-based systems by defining structs for orders and interfaces for settlement smart contracts. This standard is application-specific and aimed at designers of cross-chain intent systems, while our proposal is more general and targets developers implementing arbitrary cross-chain applications. However, an intent system based on ERC-7683 can be built on top of our standard due to its modularity. An application implementing ERC-7683 could use the Mailbox API defined in this proposal to send originData from event messages between the source chain (where user funds are deposited) and the destination chain(s) (where intents are solved). We provide more details in the Example Usage section.
No backward compatibility issues found. Since this is an opt-in protocol, L2s that do not opt-in to this will not be affected. Furthermore, this protocol can operate in existing cross-chain flows today, such as in intent-based bridges or native bridging of assets between L2s.
We show a possible implementation of the mailbox contract for synchronous messaging protocol, and explain how it can be easily modified to support asynchronous protocols as well.
The high-level flow is as follows:
chain_i.inboxDigest[chain_j] == chain_j.outboxDigest[chain_i] for all i!=j/// @title Mailbox contract implementation for synchronous communication
contract Mailbox {
// ... Constructor + other simple functions like chain_id().
/// @notice nested map: blockNum -> metadataDigest -> payload
/// @dev Easy cleanup by `delete inbox[block.number -1]`
mapping(uint256 => mapping(bytes32 => bytes)) inbox;
// Mapping to detect key collisions: metadataDigest -> writtenFlag
mapping(bytes32 => bool) outboxNullifier;
// These hash values are computed incrementally.
/// @notice Nested map: blockNum -> srcChainId -> H(...H(m_2 | H(m_1))..)
/// @dev Easy cleanup by `delete inboxDigest[block.number -1]`
mapping(uint256 => mapping(uint32 => bytes32)) inboxDigest;
/// @notice Nested map: blockNum -> destChainId -> H(...H(m_2 | H(m_1))..)
/// @dev Easy cleanup by `delete outboxDigest[block.number -1]`
mapping(uint256 => mapping(uint32 => bytes32)) outboxDigest;
/// @dev Given the metadata (Message struct without payload field) of a
/// message, derive the digest used as the dictionary key for inbox/outbox.
function getMetadataDigest(
uint32 srcChainId,
uint32 destChainId,
address srcAddress,
address destAddress,
uint256 uid
) pure public returns (bytes32) {
return
keccak(
abi.encodePacked(
srcChainId,
destChainId,
srcAddress,
destAddress,
uid
)
);
}
/// @notice Conceptual "cleanup/reset" of mailbox after each block since
/// sync msgs are received immediately.
function _resetMailbox() private {
delete inbox[block.number - 1];
delete inboxDigest[block.number - 1];
delete outboxDigest[block.number - 1];
}
/// @notice Send a message to another chain
function send(
uint32 destChainId,
address destAddress,
uint256 uid,
bytes memory payload
) public {
bytes32 key = getMetadataDigest(
this.chain_id(),
destChainId,
bytes32(srcAddress),
bytes32(msg.sender),
uid
);
// Prevent overwriting the same key
require(!outboxNullifier[key]);
outboxNullifier[key] = true;
// Update the outbox digest
// digest' = H(digest | metadata | payload)
outboxDigest[block.number][this.chain_id()] = keccak256(
abi.encodePacked(
outboxDigest[block.number][this.chain_id()],
key,
m.payload
)
);
}
/// @dev This function can only be called once per block
function populateInbox(Message[] calldata messages, bytes memory aux) public {
// Before putting new inbox messages at the beginning of each block,
// "reset" the inbox/outbox
_resetMailbox();
for (uint i = 0; i < messages.length; i++) {
Message memory m = messages[i];
// Reject if the message was not sent to this chain
require(m.destChainId == this.chain_id());
bytes32 key = getMetadataDigest(
m.srcChainid,
m.srcAddr,
this.chain_id(),
m.destAddr,
m.uid
);
inbox[key] = m.payload;
// Update the inbox digest
// digest' = H(digest | metadata | payload)
inboxDigest[block.number][m.srcChainId] = keccak256(
abi.encodePacked(
inboxDigest[block.number][m.srcChainId],
key,
m.payload
)
);
}
}
/// @notice Receive a message from another chain
function recv(
uint32 srcChainId,
address srcAddress,
address destAddress,
uint256 uid
) public returns (bytes32) {
bytes32 key = getMetadataDigest(
srcChainId,
this.chain_id(),
bytes32(srcAddress),
bytes32(destAddress),
uid
);
return inbox[block.number][key];
}
}
To extend the synchronous mailbox to support asynchronous protocols, we only need these modifications:
inbox, inboxDigest, outboxDigest, since the “domain-separation from block number” requirement is gone. (e.g. changed to mapping(bytes32 => bytes) inbox)inboxDigest,outboxDigest to ones with efficient subset proof._reset() logic since all messages for async will be permanently stored.The most costly operations are sstore during Mailbox.populateInbox(), which writes to the mapping inbox in contract storage, and sload, during Mailbox.recv() which reads from the inbox in storage. Luckily, Mailboxes costs on L2 are much cheaper. In cases of more gas-sensitive chains and applications, we suggest these potential optimizations:
delete inbox[key] during .recv() to get gas refunds for cleaning some storagemapping(bytes32 bucketKey => mapping(bytes32 => bytes)An ERC token contract wishing to allow cross-chain transfers would need to add the functions xTransfer and xReceive . The logic of a single chain transfer (e.g. Token.send) must be split into two functions Token.xTransfer and Token.xReceive. Each of these functions respectively mints and burns the same amount of assets and interact with the Mailbox contract.
/// ERC20 token contract supporting cross-chain transfers
contract XChainToken is ERC20Burnable {
/// @notice points to the Mailbox contract used
Mailbox public mailbox;
/// @notice bitmap for redeem-once control on inbox messages
mapping(bytes32 => bool) private isRedeemed;
/// @notice maps chainId to the canonical XChainToken address
mapping(uint32 => address) public xChainTokenAddress;
/// @notice use this function to transfer some amount of this token to
/// another address on another chain
/// @param destAddress receiver address
/// @param amount amount to transfer
/// @param destChainId identifier of the destination chain
function xTransfer(
uint32 destChainId,
address destAddress,
uint256 amount
) external returns (bool) {
// Burn the token of the caller
this.burn(amount);
// Write a message to the Mailbox to notify the other chain that the
// token have been successfully burnt.
bytes memory payload = abi.encodePacked(amount, destAddress); // Specify the amount to be minted and the recipient
mailbox.send(
Mailbox.Metadata(
mailbox.chain_id(),
destChainId,
bytes32(address(this)),
bytes32(xChainTokenAddress[destChainId]),
mailbox.randSessionId(),
0
),
payload
);
}
/// @notice This function must be called on the destination chain to mint
/// the tokens. This function can be called by any participant.
/// @param srcChainId identifier of the source chain the funds are sent from
/// @param sessionId unique identifier needed to fetch the message
function xReceive(uint32 srcChainId, uint128 sessionId) public {
/// Analoguous to crossTransfer except that this function can only be
/// called once with the same parameters in order to avoid double
/// minting. A mapping struct like isRedeemed can be used for this
/// purpose.
bytes memory payload = mailbox.recv(
Mailbox.Metadata(
srcChainId,
mailbox.chain_id(),
bytes32(xChainTokenAddress[srcChainId]),
bytes32(address(this)),
sessionId,
0
)
);
(uint256 amount, address destAddress) = abi.decode(
payload,
(uint256, address)
);
this.transfer(destAddress, amount);
}
}
In this example we show how to implement a cross-chain function call using the Mailbox abstraction: The logic of cross-chain execution is handled by a contract RemoteExecuter deployed on both source and destination chains.
On the source chain A, a user wanting to call a function fun of a contract Foo on the destination chain B can invoke RemoteExecuter.remoteCall with the address of the contract Foo and other parameters including the function name and its arguments. This generates a message that is sent to chain B.
On the destination chain B, a call to RemoteExecuter.execute fetches the message sent from the source chain A, parses it and executes the corresponding function of the local contract Foo. The RemoteExecuter contract also takes care of preventing messages replays.
Note that in this example gas on the destination chain is paid by the caller of RemoteExecuter.execute . In practice this participant can be the same user who called RemoteExecuter.remoteCall on the source chain. More advanced gas management policies can be implemented where another party calls RemoteExecuter.execute and pays on behalf of the user.
/// Contract deployed on both chains A and B
/// This contract takes care of receiving remote calls from the source chain
/// and of the execution on the destination chain
contract RemoteExecuter {
/// @notice points to the chain Mailbox
Mailbox public mailbox;
/// @notice maps chainId to the canonical RemoteExecuter address
/// @dev We assume the contract RemoteExecuter is deployed on both (or
/// more) chains, and this map allows to know the address of the
/// contract on the other chain(s).
mapping(uint32 => address) public remoteExecuterAddress;
// Track which messages have already been processed
mapping(bytes32 => bool) private executedMessages;
/// @notice Prepare the execution function on a another chain
/// @dev This function sends a message to the destination chain with the
/// parameters of the call
function remoteCall(
uint32 destChainId,
address remoteContractAddress,
bytes callParams
) public {
Mailbox.Metadata memory metadata = Mailbox.Metadata(
mailbox.chain_id(),
destChainId,
bytes32(address(this)),
bytes32(remoteExecuterAddress[destChainId]),
mailbox.randSessionId(),
0
);
mailbox.send(metadata, callParams);
}
/// @notice Call a contract function locally based on some message that was
/// sent from another chain
/// @param srcChainId Identifier of the source chain where the call was
/// initiated
/// @param sessionId Session identifier
function execute(uint32 srcChainId, uint128 sessionId) public {
// Check that the message has not be executed yet
bytes32 memory uid = keccak256(abi.encodePacked(sessionId, 0));
require(!this.executedMessages[uid], "already executed");
// Read the message
Mailbox.Metadata memory metadata = Mailbox.Metadata(
srcChainId,
mailbox.chain_id(),
bytes32(remoteExecuterAddress[srcChainId]),
bytes32(address(this)),
sessionId,
0
);
bytes memory payload = Mailbox.recv(metadata);
// Call the function
(address contractAddress, bytes memory callParams) = abi.decode(
payload
);
contractAddress.call{gas: 100000}(callParams);
// Mark message as executed
this.executedMessages[uid] = true;
}
}
// Contract deployed on chain A
contract Caller {
/// @notice Function on the source chain A that calls a function of a
/// contract deployed on the destination chain B
/// @dev The identifier of the destination chain CHAIN_B_ID and the remote
/// contract address FOO_CONTRACT_ADDRESS are hardcoded
/// @param val parameter to be passed to the function Foo.fun(...)
function callChainB(uint256 val) public {
bytes memory callParams = abi.encodeCall(Foo.fun(val));
RemoteExecuter.remoteCall(CHAIN_B_ID, FOO_CONTRACT_ADDRESS, callParams);
}
}
/// Contract deployed on chain B
/// We assume this contract is deployed at the address FOO_CONTRACT_ADDRESS
/// This contract has a function that is called from chain A
contract Foo {
function fun(uint256 parameter) public {
require(parameter == 42);
}
}
Security concerns for the messaging format and mailbox APIs themselves are minimal, as the protocol specification focuses on providing a rich and expressive interface for various message passing protocols. Security responsibilities lie with the underlying messaging protocol design and the application utilizing it. This specification ensures the interfaces are flexible enough to support the majority of cross-chain messaging protocols, while security within those protocols is outside the scope of this proposal.
Copyright and related rights waived via CC0.