This ERC defines a protocol for cross-rollup messaging using state commitments. Users can broadcast messages on a source chain, and those messages can be verified on any other chain that shares a common ancestor with the source chain.
A state commitment is a bytes32 hash that commits to a chain’s state (e.g., block hash or state root). The protocol supports different types of state commitments depending on what the rollup commits to its parent chain. Block hashes are the recommended state commitment, but state roots or other commitments may be used, such as batch hashes (since some rollups don’t commit single blocks, but batches of blocks instead).
Each chain deploys a singleton Receiver and Broadcaster contract. Broadcasters store messages; Receivers verify the Broadcasters’ state on remote chains. To do this, a Receiver first verifies a chain of state commitment proofs to recover a remote state commitment, then verifies the Broadcaster’s state at that commitment.
Critically, the logic for verifying state commitment proofs is not hardcoded in the Receiver. Instead, it delegates this to a user specified list of StateProver contracts. Each StateProver defines how to verify a state commitment proof for a specific home chain to recover the state commitment of a specific target chain. Because the state commitment schemes and layouts of rollup contracts can change over time, the state commitment proof verification process itself must also be upgradeable—hence the StateProvers are upgradeable. This flexible, upgradeable proof verification model is the core contribution of this standard.
Motivation
The Ethereum ecosystem is experiencing a rapid growth in the number of rollup chains. As the number of chains grows, the experience becomes more fragmented for users, creating a need for trustless “interop” between rollup chains. These rollup chains, hosted on different rollup stacks, have heterogeneous properties, and as yet there does not exist a simple, trustless, unified mechanism for sending messages between these diverse chains.
Many classes of applications could benefit from a unified system for broadcasting messages across chains. Some examples include:
Intent-Based Protocols: These protocols enable “fillers” to quickly execute crosschain actions on behalf of users, followed by slower, trustless messaging to settle these actions. However, due to the lack of a simple, unified interface for sending settlement messages, intent protocols often develop proprietary methods. This raises adoption barriers for fillers, integrators, and new protocols. A pluggable, standardized messaging solution that works across rollup stacks would allow developers and standards authors to focus on other components of the intent stack, such as fulfillment constraints, order formats, and escrow.
Governance of multichain apps: Multichain apps often have a single chain where core governance contracts are located. A standardized broadcast messaging system simplifies the dissemination of proposal results to all instances of a multichain app.
Multichain Oracles: Some classes of oracles may benefit from being able to post their data to a single chain, while having that same data easily accessible across many other chains.
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Compatibility Requirements
Chains must satisfy the following conditions to be compatible with the system:
Must store finalized state commitments on the parent chain
Must store parent chain state commitments in child chain state
Must be EVM equivalent with L1. StateProvers are deployed as copies on many chains, so need to behave the same on all those chains
A state commitment is a bytes32 hash that commits to the state of a chain at a particular point in time. This commitment is produced by the rollup and serves as a cryptographic representation of the chain’s state. The most common state commitments are:
Block hash: A hash of the block header, which is the recommended state commitment for most implementations
State root: The root of the state tree (e.g., Merkle-Patricia Trie) or other state commitment structure
Batch hash: A hash of a batch of blocks, used by some rollups that commit batches of blocks rather than individual blocks
The choice of state commitment depends on what the rollup commits to its parent chain. Different rollups may use different state commitments, and the StateProver is responsible for verifying proofs that recover these commitments from the parent chain’s state.
Broadcaster
The Broadcaster is responsible for storing messages in state to be read by Receivers on other chains. Callers of the Broadcaster are known as Publishers. The Broadcaster stores only 32 byte messages.
The Broadcaster does not accept duplicate messages from the same publisher.
Figure 1: A Publisher at address 0x4 calling a Broadcaster at address 0x3
/// @notice Broadcasts messages to receivers.
interfaceIBroadcaster{/// @notice Emitted when a message is broadcast.
/// @param message The message that was broadcast by the publisher.
/// @param publisher The address of the publisher.
eventMessageBroadcast(bytes32indexedmessage,addressindexedpublisher);/// @notice Broadcasts a message. Callers are called "publishers".
/// @dev MUST revert if the publisher has already broadcast the message.
/// MUST emit MessageBroadcast.
/// MUST store block.timestamp in slot keccak(message, msg.sender).
/// MAY use additional transmission mechanisms (e.g., child-to-parent native bridges) to make messages visible.
/// @param message The message to broadcast.
functionbroadcastMessage(bytes32message)external;}
StateProvers
StateProvers prove a unidirectional link between two chains that have direct access to each other’s finalized state commitments. The chains in this link are called the home chain and the target chain. StateProvers are responsible for verifying state commitment proofs to prove the existence of finalized target state commitments in the state of the home chain. Block hashes are the recommended state commitment, but implementations may use other commitments (e.g., state roots, batch hashes).
Since the StateProvers are unidirectional, each chain needs to have two:
One whose home is the child chain and target is the parent chain.
One whose home is the parent chain and target is the child chain.
StateProvers MUST ensure that they will have the same deployed code hash on all chains.
Figure 2: A StateProver with home chain L and target chain M
/// @notice The IStateProver is responsible for retrieving the state commitment of its target chain given its home chain's state.
/// The home chain's state is given either by a state commitment and proof, or by the StateProver executing on the home chain.
/// A single home and target chain are fixed by the logic of this contract.
interfaceIStateProver{/// @notice Verify the state commitment of the target chain given the state commitment of the home chain and a proof.
/// @dev MUST revert if called on the home chain.
/// MUST revert if the input is invalid or the input is not sufficient to determine the state commitment.
/// MUST return a target chain state commitment.
/// MUST be pure, with 1 exception: MAY read address(this).code.
/// @param homeStateCommitment The state commitment of the home chain.
/// @param input Any necessary input to determine a target chain state commitment from the home chain state commitment.
/// @return targetStateCommitment The state commitment of the target chain.
functionverifyTargetStateCommitment(bytes32homeStateCommitment,bytescalldatainput)externalviewreturns(bytes32targetStateCommitment);/// @notice Get the state commitment of the target chain. Does so by directly accessing state on the home chain.
/// @dev MUST revert if not called on the home chain.
/// MUST revert if the target chain's state commitment cannot be determined.
/// MUST return a target chain state commitment.
/// SHOULD use the input to determine a specific state commitment to return. (e.g. input could be a block number)
/// SHOULD NOT read from its own storage. This contract is not meant to have state.
/// MAY make external calls.
/// @param input Any necessary input to fetch a target chain state commitment.
/// @return targetStateCommitment The state commitment of the target chain.
functiongetTargetStateCommitment(bytescalldatainput)externalviewreturns(bytes32targetStateCommitment);/// @notice Verify a storage slot given a target chain state commitment and a proof.
/// @dev This function MUST NOT assume it is being called on the home chain.
/// MUST revert if the input is invalid or the input is not sufficient to determine a storage slot and its value.
/// MUST return a storage slot and its value on the target chain.
/// MUST be pure, with 1 exception: MAY read address(this).code.
/// While messages MUST be stored in storage slots, alternative reading mechanisms may be used in some cases.
/// @param targetStateCommitment The state commitment of the target chain.
/// @param input Any necessary input to determine a single storage slot and its value.
/// @return account The address of the account on the target chain.
/// @return slot The storage slot of the account on the target chain.
/// @return value The value of the storage slot.
functionverifyStorageSlot(bytes32targetStateCommitment,bytescalldatainput)externalviewreturns(addressaccount,uint256slot,bytes32value);/// @notice The version of the state commitment prover.
/// @dev MUST be pure, with 1 exception: MAY read address(this).code.
functionversion()externalpurereturns(uint256);}
StateProverPointers
StateProvers can be used to get or verify target state commitments, however since their verification logic is immutable, changes to the structure of the home or target chain can break the logic in these Provers. A StateProverPointer is a Pointer to a StateProver which can be updated if proving logic needs to change.
StateProverPointers are used to reference StateProvers as opposed to referencing Provers directly. To that end, wherever a StateProver is deployed a StateProverPointer needs to be deployed to reference it.
StateProverPointers allow a permissioned party to update the Prover reference within the Pointer. Choosing which party should have the permission to update the Prover reference should be carefully considered. The general rule is that if an update to the target or home chain could break the logic in the current Prover, then the party, or mechanism, able to make that update should also be given permission to update the Prover. See Security Considerations for more information on StateProverPointer ownership and updates.
When updating a StateProverPointer to point to a new StateProver implementation:
The home and target chain of the new StateProver MUST be identical to the previous StateProver.
The new StateProver MUST have a higher version than the previous StateProver.
StateProverPointers MUST store the code hash of the StateProver implementation in slot STATE_PROVER_POINTER_SLOT.
Figure 3: A StateProverPointer at address 0xA pointing to a StateProver with home chain L and target chain M
/// @title IStateProverPointer
/// @notice Keeps the code hash of the latest version of a state commitment prover.
/// MUST store the code hash in storage slot STATE_PROVER_POINTER_SLOT.
/// Different versions of the prover MUST have the same home and target chains.
/// If the pointer's prover is updated, the new prover MUST have a higher IStateProver::version() than the old one.
/// These pointers are always referred to by their address on their home chain.
interfaceIStateProverPointer{/// @notice Return the code hash of the latest version of the prover.
functionimplementationCodeHash()externalviewreturns(bytes32);/// @notice Return the address of the latest version of the prover on the home chain.
functionimplementationAddress()externalviewreturns(address);}
Routes
A route is a relative path from a Receiver on a local chain to a remote chain. It is constructed of many single degree links dictated by StateProverPointers. Receivers use the StateProvers that the Pointers reference to verify a series of proofs to obtain the remote chain’s state commitment. A route works with any state commitment scheme (block hashes, state roots, etc.) and is defined by the list of Pointer addresses on their home chains.
A valid route MUST obey the following:
Home chain of the route[0] Pointer must equal the local chain
Target chain of the route[i] Pointer must equal home chain of the route[i+1] Pointer
Figure 4: A route [0xA, 0xB, 0xC] from chain L to chain R
Chain L is an L2, Chain M is Ethereum Mainnet, Chain P is another L2, and Chain R is an L3 settling to Chain P
Identifiers
Accounts on remote chains are identified by the route taken from the local chain plus the address on the remote chain. The Pointer addresses used in the route, along with the remote address, are cumulatively keccak256 hashed together to form a Remote Account ID.
In this way any address on a remote chain, including Pointers and Broadcasters, can be uniquely identified relative to the local chain by their Remote Account ID.
ID’s depend on a route and are therefore always relative to a local chain. In other words, the same account on a given chain will have different ID’s depending on the route from the local chain.
The Remote Account ID is defined as accumulator([...route, remoteAddress])
The Remote Account ID of Broadcaster at 0x3 is accumulator([0xA, 0xB, 0xC, 0x3])
The Remote Account ID of StateProverPointer 0xC is accumulator([0xA, 0xB, 0xC]).
StateProverCopies
StateProverCopies are exact copies of StateProvers deployed on non-home chains. When a StateProver code hash is de-referenced from a Pointer, a copy of the StateProver may be used to execute its logic. Since the Pointer references the prover by code hash, a local copy of the Prover can be deployed and used to execute specific proving logic. The Receiver caches a map of mapping(bytes32 stateProverPointerId => IStateProver stateProverCopy) to keep track of StateProverCopies.
Figure 5: A StateProverCopy of StateProver M->P on chain L
Receiver
The Receiver is responsible for verifying 32 byte messages deposited in Broadcasters on other chains. The caller provides the Receiver with a route to the remote account and proof to verify the route.
Figure 6: Example of a Receiver reading a message from a Broadcaster on chain R
The calls in Figure 6 perform the following operations:
Receiver calls IStateProverPointer(0xA)::implementationAddress to get the address of StateProver L->M
Receiver calls IStateProver(Prover L->M)::getTargetState, passing input given by Subscriber to get a state commitment of chain M.
Receiver calls IStateProver(Prover Copy M->P)::verifyTargetState, passing chain M’s state commitment and proof data by Subscriber to get a state commitment of chain P.
Receiver calls IStateProver(Prover Copy P->R)::verifyTargetState, passing chain P’s state commitment and proof data by Subscriber to get a state commitment of chain R.
Finally, Receiver calls IStateProver(Prover Copy P->R)::verifyStateValue, passing input given by Subscriber to get a storage slot from the Broadcaster. The Receiver returns the Broadcaster’s Remote Account ID and the message’s timestamp to Subscriber.
/// @notice Reads messages from a broadcaster.
interfaceIReceiver{/// @notice Arguments required to read state of an account on a remote chain.
/// @dev The proof is always for a single storage slot. If the proof is for multiple slots the IReceiver MUST revert.
/// The proof format depends on the state commitment scheme used by the StateProver (e.g., storage proofs).
/// While messages MUST be stored in storage slots, alternative reading mechanisms may be used in some cases.
/// @param route The home chain addresses of the StateProverPointers along the route to the remote chain.
/// @param scpInputs The inputs to the StateProver / StateProverCopies.
/// @param proof Proof passed to the last StateProver / StateProverCopy
/// to verify a storage slot given a target state commitment.
structRemoteReadArgs{address[]route;bytes[]scpInputs;bytesproof;}/// @notice Reads a broadcast message from a remote chain.
/// @param broadcasterReadArgs A RemoteReadArgs object:
/// - The route points to the broadcasting chain
/// - The account proof is for the broadcaster's account
/// - The proof is for the message storage slot (MAY accept proofs of other transmission mechanisms (e.g., child-to-parent native bridges) if the broadcaster contract uses other transmission mechanisms)
/// @param message The message to read.
/// @param publisher The address of the publisher who broadcast the message.
/// @return broadcasterId The broadcaster's unique identifier.
/// @return timestamp The timestamp when the message was broadcast.
functionverifyBroadcastMessage(RemoteReadArgscalldatabroadcasterReadArgs,bytes32message,addresspublisher)externalviewreturns(bytes32broadcasterId,uint256timestamp);/// @notice Updates the state commitment prover copy in storage.
/// Checks that StateProverCopy has the same code hash as stored in the StateProverPointer
/// Checks that the version is increasing.
/// @param scpPointerReadArgs A RemoteReadArgs object:
/// - The route points to the StateProverPointer's home chain
/// - The account proof is for the StateProverPointer's account
/// - The proof is for the STATE_PROVER_POINTER_SLOT
/// @param scpCopy The StateProver copy on the local chain.
/// @return scpPointerId The ID of the StateProverPointer
functionupdateStateProverCopy(RemoteReadArgscalldatascpPointerReadArgs,IStateProverscpCopy)externalreturns(bytes32scpPointerId);/// @notice The StateProverCopy on the local chain corresponding to the scpPointerId
/// MUST return 0 if the StateProverPointer does not exist.
functionstateProverCopy(bytes32scpPointerId)externalviewreturns(IStateProverscpCopy);}
Rationale
Broadcast vs Unicast
A contract on any given chain cannot dictate which other chains can and cannot inspect its state. Contracts are naturally broadcasting their state to anything capable of reading it. Targeted messaging applications can always be built on top of a broadcast messaging system.
Message reading SHOULD use storage proofs to read messages from storage slots. However, an alternative method to this would be to pass messages (perhaps batched) via the canonical bridges of the chains. However storage proofs have some advantages over this method:
They only require gas tokens on the chains where the message is sent and received, none on the chains on the route in between.
Batching by default. Since storage slots share a common state root, caching the state root allows readers to open adjacent slots at lower cost. This provides a form of implicit batching, whereas canonical bridges would need to create a form of explicit batching.
If the common ancestor of the two chains is Ethereum, sending a message using the canonical bridges would require sending a transaction on Ethereum, which would likely incur a high cost.
No duplicate messages per publisher
To allow publishers to send the same message multiple times, some kind of nonce system would need to exist in this ERC. Since nonces can be implemented at the Publisher / Subscriber layer, and not all Publishers / Subscribers require this feature, it is left out of this ERC.
Cost Comparison
Here we compare the cost of using storage proofs vs sending messages via the canonical bridge, where the parent chain is Ethereum. Here, we will only consider the cost of the L1 gas as we assume it to dominate the L2 gas costs.
Each step along the route requires 1 storage proof. These proofs can be estimated at roughly 6.5k bytes. These proofs will likely be submitted on an L2/L3 and therefore be included in blobs on the L1, which have a fluctuating blob gas price. Since rollups can dynamically switch between calldata and blobs, we can work out a maximum amount of normal L1 gas that could be using the standard cost of calldata as an upper bound. Post Pectra, the upper bound for non-zero-byte calldata is 40 gas per byte, which for 6.5k bytes equates to 260,000 L1 gas.
We want to compare this to sending a single message via a canonical rollup bridge, which is either a parent->child or child->parent message. This estimate is dependent on specific implementations of the bridge for different rollup frameworks, but we estimate it to be around 150,000 gas.
This puts the upper bound of the storage proof to be around 2x that of the canonical bridge, but in practice this upper bound is rarely reached. On top of that, the Receiver can implement a caching policy allowing many messages to share the same storage proofs.
Caching
This ERC does not currently describe how the Receiver can cache the results of storage proofs to improve efficiency. In brief, once a storage proof is executed it never needs to be executed again, and instead the result can be stored by the Receiver. This allows messages that share the same, or partially the same, route to share previously executed proofs and instead lookup the result. As an example we can consider the route between two L2s using storage proofs:
Ethereum block hash is looked up directly on L2’ by the Receiver on L2’
The block hash of L2’’ is proven using a storage proof
The account root of the Broadcaster on L2’’ is proven using a storage proof
The slot value in the Broadcaster account is proven using a storage proof
The result of everything up to step 4 in this process can be stored in a Receiver cache and re-used by any unread messages in the Broadcaster. The Receiver can even go further and cache individual nodes in the account trie to make step 4. cheaper for previous messages.
Using Routes in Identifiers
Chains are often identified by chain ID’s. Chain ID’s are set by the chain owner so they are not guaranteed to be unique. Using the addresses of the Pointers is guaranteed to be unique as it provides a way to unwrap the nested state commitments embedded in the state roots. A storage slot on a remote chain can be identified by many different remote account ID’s, but one remote account ID cannot identify more than one storage slot.
StateProvers, Pointers, and Copies
StateProvers
Each rollup implements unique logic for managing and storing state commitments. To accommodate this diversity, StateProvers implement chain-specific procedures. This flexibility allows integration with each rollup’s distinct architecture and state commitment scheme.
The StateProver handles the final step of verifying a storage slot given a target state commitment to accommodate rollups with differing state commitment schemes and formats.
StateProverPointers
Routes reference StateProvers through Pointers rather than directly. This indirection is crucial because:
Chain upgrades may require StateProver redeployments
Routes must remain stable and valid across these upgrades - ensuring in-flight messages are not broken
Pointers maintain route consistency while allowing StateProver implementations to evolve
StateProverCopies
Since StateProverPointers reference StateProvers via their code hash, a copy of the StateProver can be deployed anywhere and reliably understood to contain the same code as that referenced by the Pointer. This allows the Receiver to locally use the code of a StateProver whose home chain is a remote chain.
Reference Implementation
The following is an example of a one-way crosschain token migrator. The burn side of the migrator is a publisher which sends burn messages through a Broadcaster. The mint side subscribes to these burn messages through a Receiver on another chain.
/// @notice Message format for the burn and mint migrator.
structBurnMessage{addressmintTo;uint256amount;uint256nonce;}
/// @notice The burn side of an example one-way cross chain token migrator.
/// @dev This contract is considered a "publisher"
contractBurner{/// @notice The token to burn.
IERC20publicimmutableburnToken;/// @notice The broadcaster to publish messages through.
IBroadcasterpublicimmutablebroadcaster;/// @notice An incrementing nonce, so each burn is a unique message.
uint256publicburnCount;/// @notice Event emitted when tokens are burned.
/// @dev Publishers SHOULD emit enough information to reconstruct the message.
eventBurn(BurnMessagemessageData);constructor(IERC20_burnToken,IBroadcaster_broadcaster){burnToken=_burnToken;broadcaster=_broadcaster;}/// @notice Burn the tokens and broadcast the event.
/// The corresponding token minter will subscribe to the message on another chain and mint the tokens.
functionburn(addressmintTo,uint256amount)external{// first, pull in the tokens and burn them
burnToken.transferFrom(msg.sender,address(this),amount);burnToken.burn(amount);// next, build a unique message
BurnMessagememorymessageData=BurnMessage({mintTo:mintTo,amount:amount,nonce:burnCount++});bytes32message=keccak256(abi.encode(messageData));// finally, broadcast the message
broadcaster.broadcastMessage(message);emitBurn(messageData);}}
/// @notice The mint side of an example one-way cross chain token migrator.
/// This contract must be given minting permissions on its token.
/// @dev This contract is considered a "subscriber"
contractMinter{/// @notice Address of the Burner contract on the other chain.
addresspublicimmutableburner;/// @notice The BroadcasterID corresponding to the broadcaster on the other chain that the Burner uses.
/// The Minter will only accept messages published by the Burner through this Broadcaster.
bytes32publicimmutablebroadcasterId;/// @notice The receiver to listen for messages through.
IReceiverpublicimmutablereceiver;/// @notice A mapping to keep track of which messages have been processed.
/// Subscribers SHOULD keep track of processed messages because the Receiver does not.
/// The Broadcaster ensures messages are unique, so true duplicates are not possible.
mapping(bytes32=>bool)publicprocessedMessages;/// @notice The token to mint.
IERC20publicimmutablemintToken;constructor(address_burner,bytes32_broadcasterId,IReceiver_receiver,IERC20_mintToken){burner=_burner;broadcasterId=_broadcasterId;receiver=_receiver;mintToken=_mintToken;}/// @notice Mint the tokens when a message is received.
functionmintTokens(IReceiver.RemoteReadArgscalldatabroadcasterReadArgs,BurnMessagecalldatamessageData)external{// calculate the message from the data
bytes32message=keccak256(abi.encode(messageData));// ensure the message has not been processed
require(!processedMessages[message],"Minter: Message already processed");// verify the broadcast message
(bytes32actualBroadcasterId,)=receiver.verifyBroadcastMessage(broadcasterReadArgs,message,burner);// ensure the message is from the expected broadcaster
require(actualBroadcasterId==broadcasterId,"Minter: Invalid broadcaster ID");// mark the message as processed
processedMessages[message]=true;// mint tokens to the recipient
mintToken.mint(messageData.mintTo,messageData.amount);}}
Security Considerations
Chain Upgrades
If a chain upgrades such that a StateProver’s verifyTargetState or getTargetState functions might return data besides a finalized target state commitment, then invalid messages could be read by a Receiver. For instance, if a chain stores its state commitments on the parent chain in a specific mapping, and that storage slot is later repurposed, then an old StateProver might be able to pass along an invalid state commitment. It is therefore important that either:
the StateProver is written in such a way to detect changes like this
the owner who is able to repurpose these storage slots is aware of the StateProver and ensures they don’t break it
StateProverPointer Ownership / Updates
A malicious StateProverPointer owner can DoS or forge messages. However, so can the chain owner responsible for setting the slot of historical parent/child state commitments. Therefore it is expected that this chain owner be the same as the owner of the StateProverPointer so as not to introduce additional risks.
If the target chain of the referenced StateProver is the parent chain, the home chain owner is expected to be the StateProverPointer’s owner.
If the target chain of the referenced StateProver is the child chain, the target chain owner is expected to be the StateProverPointer’s owner.
If an owner neglects their responsibility to update the Pointer with new StateProver implementations when necessary, messages could fail to reach their destinations.
If an owner maliciously updates a Pointer to point to a StateProver that produces fraudulent results, messages can be forged.
If there is confidence that a chain along the route connecting them will not upgrade to break a StateProver, an unowned StateProverPointer can be deployed in the absence of a properly owned one.
Message guarantees
This ERC describes a protocol for ensuring that messages from remote chains CAN be read, but not that they WILL be read. It is the responsibility of the Receiver caller to choose which messages they wish to read.
Since the ERC only uses finalized blocks, messages may take a long time to propagate between chains. Finalisation occurs sequentially in the route, therefore time to read a message is the sum of the finalisation of each of the state commitments at each step in the route.